From 6268385cf8cfc03b2b0183e6d369364be06e10fc Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 7 Sep 2026 14:41:47 +0700 Subject: [PATCH 01/56] fix: carry reviewed npm audit into protected builds Signed-off-by: San Dang --- Dockerfile | 28 +- Dockerfile.protected-npm-audit | 31 ++ scripts/audit-reviewed-npm-graph.mts | 51 ++++ .../checks/build-protected-managed-images.sh | 80 +++++ .../materialize-locked-npm-cache-seed.mts | 100 ++++++- scripts/lib/reviewed-npm-audit.mts | 281 ++++++++++++++---- scripts/lib/verify-mcporter-audit.sh | 59 ++++ src/lib/sandbox/build-context.ts | 4 + .../releases/npm-audit-receipt.test.ts | 6 + .../reviewed-npm-audit-workflow.test.ts | 2 +- .../releases/reviewed-npm-audit.test.ts | 133 ++++++++- .../materialize-locked-npm-cache-seed.test.ts | 95 ++++++ ...otected-managed-image-build-script.test.ts | 114 ++++++- .../sandbox/sandbox-build-context.test.ts | 6 + test/security/mcporter-audit-evidence.test.ts | 124 ++++++++ test/security/mcporter-supply-chain.test.ts | 46 ++- 16 files changed, 1069 insertions(+), 91 deletions(-) create mode 100644 Dockerfile.protected-npm-audit create mode 100755 scripts/lib/verify-mcporter-audit.sh create mode 100644 test/security/mcporter-audit-evidence.test.ts diff --git a/Dockerfile b/Dockerfile index cf22361afed..f3b5df01a1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -546,6 +546,7 @@ COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts +COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -599,6 +600,13 @@ COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/ COPY --from=mcp-tool-discovery-runtime /opt/mcp-tool-discovery-runtime/dist/ /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime/ +# Keep optional audit evidence in an uncommitted mount source. The checked-in +# seed has no reviewed-npm-audit directory, so ordinary builds still perform a +# live audit. Protected builders import validated evidence into the seed before +# invoking this Dockerfile with network access disabled. +FROM scratch AS protected-mcporter-audit-cache +COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/ /seed/ + # Stage 3: Runtime image — pull cached base from GHCR # hadolint ignore=DL3006 FROM ${BASE_IMAGE} @@ -823,6 +831,7 @@ RUN command -v codex-acp >/dev/null RUN --network=default \ --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ + --mount=type=bind,from=protected-mcporter-audit-cache,source=/seed,target=/run/nemoclaw-mcporter-audit-cache \ set -eu; \ if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ @@ -986,24 +995,7 @@ RUN --network=default \ ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter; \ test "$(mcporter --version)" = "$MCPORTER_VERSION"; \ fi; \ - MCPORTER_RECEIPT=/run/secrets/nemoclaw-mcporter-audit-receipt; \ - MCPORTER_RAW_REPORT=/run/secrets/nemoclaw-mcporter-audit-raw-report; \ - if [ -f "$MCPORTER_RECEIPT" ] || [ -f "$MCPORTER_RAW_REPORT" ] || [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]; then \ - [ -f "$MCPORTER_RECEIPT" ] && [ -f "$MCPORTER_RAW_REPORT" ] && printf %s "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}' \ - || { echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2; exit 1; }; \ - printf '%s %s\n' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" "$MCPORTER_RECEIPT" | sha256sum -c -; \ -node --experimental-strip-types /scripts/lib/npm-audit-receipt.mts \ ---receipt "$MCPORTER_RECEIPT" \ ---package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ ---package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ ---raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ ---graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ ---registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true; \ - else \ - node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ - --directory /usr/local/lib/nemoclaw/mcporter-runtime \ - --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high; \ - fi + bash /scripts/lib/verify-mcporter-audit.sh # Patch OpenClaw media fetch for proxy-only sandbox (NVIDIA/NemoClaw#1755). # diff --git a/Dockerfile.protected-npm-audit b/Dockerfile.protected-npm-audit new file mode 100644 index 00000000000..e5f6ddc5b5a --- /dev/null +++ b/Dockerfile.protected-npm-audit @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Produce reviewed mcporter audit evidence on the networked managed-image +# builder so the protected rebuild can stay fully offline. +FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c AS protected-mcporter-audit +ENV RUNNER_TEMP=/tmp +WORKDIR /opt/nemoclaw-audit +COPY .github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh .github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh +COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json ci/ +COPY scripts/audit-reviewed-npm-graph.mts scripts/audit-reviewed-npm-graph.mts +COPY scripts/lib/npm-audit-receipt.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/repository-input-path.mts scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/ +COPY agents/openclaw/mcporter-runtime/package.json agents/openclaw/mcporter-runtime/package-lock.json agents/openclaw/mcporter-runtime/ +# hadolint ignore=DL3016,DL4006,SC2155 +RUN --network=default set -eu; \ + export NEMOCLAW_REVIEWED_NPM_VERSION="$(node -p "require('./ci/reviewed-npm-audit.json').npmVersion")"; \ + export NEMOCLAW_REVIEWED_NPM_INTEGRITY="$(node -p "require('./ci/reviewed-npm-audit.json').npmIntegrity")"; \ + env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN \ + bash .github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh; \ + test "$(npm --version)" = "$NEMOCLAW_REVIEWED_NPM_VERSION" +RUN --network=default env \ + -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN \ + NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH=mcporter-runtime \ + NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR=artifacts/reviewed-npm-audit \ + NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ \ + NPM_CONFIG_USERCONFIG=/dev/null \ + node --experimental-strip-types scripts/audit-reviewed-npm-graph.mts + +FROM scratch AS protected-mcporter-audit-evidence +COPY --from=protected-mcporter-audit --chmod=0400 /opt/nemoclaw-audit/artifacts/reviewed-npm-audit/mcporter-runtime.receipt.json /mcporter-runtime.receipt.json +COPY --from=protected-mcporter-audit --chmod=0400 /opt/nemoclaw-audit/artifacts/reviewed-npm-audit/mcporter-runtime.raw.json /mcporter-runtime.raw.json diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index d3ea94f4ea7..3989405c983 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -72,6 +72,16 @@ type ReviewedAuditReport = Readonly<{ threshold?: Severity; }>; +export function selectLockedGraph( + config: Readonly<{ lockedGraphs: readonly LockedGraph[] }>, + graphId: string | undefined, +): Readonly<{ graph: LockedGraph; index: number }> | undefined { + if (!graphId) return undefined; + const index = config.lockedGraphs.findIndex((graph) => graph.id === graphId); + if (index < 0) throw new Error("reviewed npm audit locked graph is not configured"); + return { graph: config.lockedGraphs[index]!, index }; +} + const TRUSTED_REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const TARGET_REPO_ROOT = fs.realpathSync( path.resolve(process.env.NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT ?? TRUSTED_REPO_ROOT), @@ -873,6 +883,10 @@ export function assertReviewedAuditReportsPass( function main(): void { const config = readConfig(); + const selectedLockedGraph = selectLockedGraph( + config, + process.env.NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH, + ); const expectedNode = `v${config.nodeVersion}`; if (process.version !== expectedNode) { throw new Error(`reviewed npm audit requires Node ${expectedNode}; running ${process.version}`); @@ -901,6 +915,43 @@ function main(): void { } const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-audit-")); try { + if (selectedLockedGraph) { + const { graph, index } = selectedLockedGraph; + const result = auditLockedGraph( + graph, + index, + config, + tempRoot, + exceptionFile, + artifactDirectory, + npmVersion, + ); + assertReviewedAuditReportsPass( + [{ label: graph.label, threshold: graph.severityThreshold, result }], + config.severityThreshold, + ); + emitAuditReceipt({ + artifactDirectory, + graphId: graph.id, + npmVersion, + packageJsonFile: targetRepositoryPath( + path.join(graph.directory, "package.json"), + `${graph.label} package manifest`, + ), + packageLockFile: targetRepositoryPath( + path.join(graph.directory, "package-lock.json"), + `${graph.label} lockfile`, + ), + rawReportFile: path.join( + artifactDirectory, + `locked-graph-${index + 1}.json`, + ), + registryOrigin: NPM_AUDIT_REGISTRY, + result, + threshold: graph.severityThreshold ?? config.severityThreshold, + }); + return; + } const sourceResult = auditSourceGraph( config, tempRoot, diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index d7cde196dac..746dacb07f6 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -167,6 +167,65 @@ for command in curl docker jq node sha256sum; do } done +audit_evidence_dir="" +audit_receipt="" +audit_raw_report="" +audit_receipt_sha256="" +if [[ -n "$cache_to" ]]; then + audit_evidence_dir="$cache_to/reviewed-npm-audit" + docker buildx build \ + --file "$source_root/Dockerfile.protected-npm-audit" \ + --platform "$platform" \ + --target protected-mcporter-audit-evidence \ + --output "type=local,dest=${audit_evidence_dir}" \ + --provenance=false \ + --sbom=false \ + "$source_root" +elif [[ -n "$cache_from" ]]; then + audit_evidence_dir="$cache_from/reviewed-npm-audit" +fi + +if [[ -n "$audit_evidence_dir" ]]; then + [[ -d "$audit_evidence_dir" && ! -L "$audit_evidence_dir" ]] || { + echo "ERROR: protected managed-image cache has no reviewed mcporter audit evidence" >&2 + exit 1 + } + [[ -z "$(find "$audit_evidence_dir" -type l -print -quit)" ]] || { + echo "ERROR: protected managed-image reviewed audit evidence contains a symlink" >&2 + exit 1 + } + audit_receipt="$audit_evidence_dir/mcporter-runtime.receipt.json" + audit_raw_report="$audit_evidence_dir/mcporter-runtime.raw.json" + audit_receipt_sha_file="$audit_evidence_dir/mcporter-runtime.receipt.sha256" + [[ -f "$audit_receipt" && ! -L "$audit_receipt" && -s "$audit_receipt" ]] || { + echo "ERROR: protected managed-image reviewed audit receipt is missing or unsafe" >&2 + exit 1 + } + [[ -f "$audit_raw_report" && ! -L "$audit_raw_report" && -s "$audit_raw_report" ]] || { + echo "ERROR: protected managed-image reviewed audit raw report is missing or unsafe" >&2 + exit 1 + } + audit_receipt_sha256="$(sha256sum "$audit_receipt" | awk '{print $1}')" + [[ "$audit_receipt_sha256" =~ ^[a-f0-9]{64}$ ]] || { + echo "ERROR: protected managed-image reviewed audit receipt hash is invalid" >&2 + exit 1 + } + if [[ -n "$cache_to" ]]; then + printf '%s\n' "$audit_receipt_sha256" >"$audit_receipt_sha_file" + chmod 0400 "$audit_receipt_sha_file" + else + [[ -f "$audit_receipt_sha_file" && ! -L "$audit_receipt_sha_file" ]] || { + echo "ERROR: protected managed-image reviewed audit receipt hash is missing or unsafe" >&2 + exit 1 + } + read -r recorded_audit_receipt_sha256 <"$audit_receipt_sha_file" + [[ "$recorded_audit_receipt_sha256" =~ ^[a-f0-9]{64}$ && "$recorded_audit_receipt_sha256" == "$audit_receipt_sha256" ]] || { + echo "ERROR: protected managed-image reviewed audit receipt hash does not match" >&2 + exit 1 + } + fi +fi + work_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/nemoclaw-protected-images.XXXXXX")" seed_overlay_active=0 seed_backup="$work_dir/npm-cache-seed-original" @@ -193,6 +252,17 @@ trap restore_worktree EXIT trap 'exit 130' INT trap 'exit 143' TERM +if [[ -n "$cache_to" ]]; then + cp -pR -- "$source_seed_dir" "$seed_backup" + seed_overlay_active=1 + install -d -m 0700 "$source_seed_dir/reviewed-npm-audit" + install -m 0400 \ + "$audit_receipt" \ + "$audit_raw_report" \ + "$audit_receipt_sha_file" \ + "$source_seed_dir/reviewed-npm-audit/" +fi + if [[ -n "$cache_from" ]]; then imported_seed="$work_dir/npm-cache-seed-import" node --experimental-strip-types --no-warnings "$seed_helper" copy \ @@ -363,6 +433,13 @@ build_agent() { cache_args+=(--cache-from "type=local,src=${cache_source}") fi fi + if [[ "$agent" == "openclaw" && -n "$audit_receipt" ]]; then + cache_args+=( + --secret "id=nemoclaw-mcporter-audit-receipt,src=${audit_receipt}" + --secret "id=nemoclaw-mcporter-audit-raw-report,src=${audit_raw_report}" + --build-arg "NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=${audit_receipt_sha256}" + ) + fi local base_digest="${base_reference##*@}" docker buildx imagetools inspect "$base_reference" --raw >"$exact_base_raw" @@ -489,6 +566,9 @@ if [[ -n "$cache_to" ]]; then --os "$npm_target_os" \ --cpu "$npm_target_cpu" \ --libc "$npm_target_libc" + cp -pR -- \ + "$source_seed_dir/reviewed-npm-audit" \ + "$cache_to/npm-cache-seed/reviewed-npm-audit" node --experimental-strip-types --no-warnings "$seed_helper" export \ --lockfile "$source_mcp_lockfile" \ --output "$cache_to/mcp-runtime-npm-cache-seed" \ diff --git a/scripts/checks/materialize-locked-npm-cache-seed.mts b/scripts/checks/materialize-locked-npm-cache-seed.mts index c446f44be70..7891c90f322 100644 --- a/scripts/checks/materialize-locked-npm-cache-seed.mts +++ b/scripts/checks/materialize-locked-npm-cache-seed.mts @@ -7,6 +7,7 @@ import { chmod, copyFile, lstat, + mkdir, mkdtemp, open, readdir, @@ -22,6 +23,15 @@ const MANIFEST_KIND = "nemoclaw-locked-npm-cache-seed-v1"; const MANIFEST_NAME = "manifest.json"; const REGISTRY_ORIGIN = "https://registry.npmjs.org"; const MAX_ARCHIVE_BYTES = 32 * 1024 * 1024; +const MAX_AUDIT_RAW_REPORT_BYTES = 64 * 1024 * 1024; +const MAX_AUDIT_RECEIPT_BYTES = 64 * 1024; +const MAX_AUDIT_RECEIPT_SHA256_BYTES = 65; +const REVIEWED_AUDIT_DIRECTORY = "reviewed-npm-audit"; +const REVIEWED_AUDIT_FILES = [ + "mcporter-runtime.raw.json", + "mcporter-runtime.receipt.json", + "mcporter-runtime.receipt.sha256", +] as const; const DOWNLOAD_CONCURRENCY = 6; const DOWNLOAD_ATTEMPTS = 4; const DOWNLOAD_TIMEOUT_MS = 30_000; @@ -93,6 +103,71 @@ function lockSha256(source: Uint8Array): string { return crypto.createHash("sha256").update(source).digest("hex"); } +type ReviewedAuditEvidence = Readonly<{ + rawReport: Buffer; + receipt: Buffer; + receiptSha256: Buffer; +}>; + +async function reviewedAuditEvidence(seed: string): Promise { + const directory = path.join(seed, REVIEWED_AUDIT_DIRECTORY); + let status; + try { + status = await lstat(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + if (!status.isDirectory() || status.isSymbolicLink()) { + throw new Error("reviewed npm audit evidence must be one non-symlink directory"); + } + const entries = await readdir(directory, { withFileTypes: true }); + if ( + entries.some((entry) => !entry.isFile() || entry.isSymbolicLink()) || + JSON.stringify(entries.map(({ name }) => name).sort()) !== + JSON.stringify([...REVIEWED_AUDIT_FILES].sort()) + ) { + throw new Error("reviewed npm audit evidence contains missing or unexpected files"); + } + const rawReport = await exactFileSource( + path.join(directory, REVIEWED_AUDIT_FILES[0]), + "reviewed npm audit raw report", + MAX_AUDIT_RAW_REPORT_BYTES, + ); + const receipt = await exactFileSource( + path.join(directory, REVIEWED_AUDIT_FILES[1]), + "reviewed npm audit receipt", + MAX_AUDIT_RECEIPT_BYTES, + ); + const receiptSha256 = await exactFileSource( + path.join(directory, REVIEWED_AUDIT_FILES[2]), + "reviewed npm audit receipt hash", + MAX_AUDIT_RECEIPT_SHA256_BYTES, + ); + if (rawReport.byteLength < 1 || receipt.byteLength < 1) { + throw new Error("reviewed npm audit evidence size is invalid"); + } + const recordedReceiptSha256 = receiptSha256.toString("utf8"); + const actualReceiptSha256 = `${lockSha256(receipt)}\n`; + if (recordedReceiptSha256 !== actualReceiptSha256) { + throw new Error("reviewed npm audit receipt hash does not match"); + } + let parsedReceipt: JsonRecord; + try { + parsedReceipt = record(JSON.parse(receipt.toString("utf8")), "reviewed npm audit receipt"); + } catch { + throw new Error("reviewed npm audit receipt is not valid JSON"); + } + if ( + typeof parsedReceipt.rawResponseSha256 !== "string" || + !/^[a-f0-9]{64}$/u.test(parsedReceipt.rawResponseSha256) || + parsedReceipt.rawResponseSha256 !== lockSha256(rawReport) + ) { + throw new Error("reviewed npm audit raw report hash does not match its receipt"); + } + return { rawReport, receipt, receiptSha256 }; +} + function archiveIntegrity(source: Uint8Array): string { return `sha512-${crypto.createHash("sha512").update(source).digest("base64")}`; } @@ -251,7 +326,7 @@ export function lockedArchives( return [...byArchive.values()].sort((left, right) => left.archive.localeCompare(right.archive)); } -async function exactFileSource(file: string, label: string): Promise { +async function exactFileSource(file: string, label: string, maxBytes?: number): Promise { if (!path.isAbsolute(file) || file.includes("\n")) { throw new Error(`${label} must be an absolute path`); } @@ -260,7 +335,7 @@ async function exactFileSource(file: string, label: string): Promise { }); try { const status = await handle.stat(); - if (!status.isFile()) { + if (!status.isFile() || (maxBytes !== undefined && status.size > maxBytes)) { throw new Error(`${label} must be one regular non-symlink file`); } return await handle.readFile(); @@ -461,6 +536,7 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { const target = exactTarget(options.target); const expected = lockedArchives(lockSource.toString("utf8"), target); const seed = await exactDirectory(options.seed, "seed directory"); + const auditEvidence = await reviewedAuditEvidence(seed); const manifestSource = await exactFileSource(path.join(seed, MANIFEST_NAME), "seed manifest"); const manifest = parseManifest(manifestSource.toString("utf8")); if (manifest.lockSha256 !== lockSha256(lockSource)) { @@ -477,7 +553,11 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { throw new Error("npm cache seed manifest does not contain the complete locked archive set"); } const entries = await readdir(seed, { withFileTypes: true }); - const expectedNames = [...expected.map(({ archive }) => archive), MANIFEST_NAME].sort(); + const expectedNames = [ + ...expected.map(({ archive }) => archive), + MANIFEST_NAME, + ...(auditEvidence ? [REVIEWED_AUDIT_DIRECTORY] : []), + ].sort(); const actualNames = entries.map(({ name }) => name).sort(); if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) { throw new Error("npm cache seed directory contains missing or unexpected files"); @@ -502,6 +582,20 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { await copyFile(path.join(seed, archive.archive), destination); await chmod(destination, 0o444); } + if (auditEvidence) { + const auditDirectory = path.join(directory.temporary, REVIEWED_AUDIT_DIRECTORY); + await mkdir(auditDirectory, { mode: 0o700 }); + for (const [name, contents] of [ + [REVIEWED_AUDIT_FILES[0], auditEvidence.rawReport], + [REVIEWED_AUDIT_FILES[1], auditEvidence.receipt], + [REVIEWED_AUDIT_FILES[2], auditEvidence.receiptSha256], + ] as const) { + await writeFile(path.join(auditDirectory, name), contents, { + flag: "wx", + mode: 0o400, + }); + } + } await directory.commit(); } catch (error) { await rm(directory.temporary, { force: true, recursive: true }); diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index b79b271c930..0c69ac41264 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -133,7 +133,34 @@ type NpmAuditCommandResult = Readonly<{ stdout: string; }>; -type NpmAuditRetryReason = "empty-output" | "incomplete-report" | "invalid-json" | "timeout"; +export type NpmAuditFailureReason = + | "empty-output" + | "incomplete-report" + | "invalid-exit-status" + | "invalid-json" + | "npm-error-document" + | "registry-network-error" + | "timeout"; + +export type NpmAuditFailureClassification = Readonly<{ + diagnostic: string; + reason: NpmAuditFailureReason; + retryable: boolean; +}>; + +export type NpmAuditResponseClassification = + | Readonly<{ failure: NpmAuditFailureClassification }> + | Readonly<{ report: Record }>; + +const TRANSIENT_TRANSPORT_CODES = [ + "EAI_AGAIN", + "ECONNREFUSED", + "ECONNRESET", + "EHOSTUNREACH", + "ENETUNREACH", + "ENOTFOUND", + "ETIMEDOUT", +] as const; function asRecord(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -276,57 +303,177 @@ export function assertExceptionGraphs( throw new Error(`npm audit exceptions use unknown graphs: ${unknown.join(", ")}`); } -export function parseAuditReport(result: { +function valueShape(value: unknown): string { + if (value === undefined) return "missing"; + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (typeof value === "number") { + if (!Number.isFinite(value)) return "non-finite-number"; + if (!Number.isSafeInteger(value)) return "non-integer-number"; + if (value < 0) return "negative-number"; + } + return typeof value; +} + +function firstInvalidAuditField(value: unknown): string | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return `report:${valueShape(value)}`; + } + const report = value as Record; + if ( + typeof report.metadata !== "object" || + report.metadata === null || + Array.isArray(report.metadata) + ) { + return `metadata:${valueShape(report.metadata)}`; + } + const metadata = report.metadata as Record; + if ( + typeof metadata.vulnerabilities !== "object" || + metadata.vulnerabilities === null || + Array.isArray(metadata.vulnerabilities) + ) { + return `metadata.vulnerabilities:${valueShape(metadata.vulnerabilities)}`; + } + const vulnerabilities = metadata.vulnerabilities as Record; + for (const severity of SEVERITIES) { + const count = vulnerabilities[severity]; + if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) { + return `metadata.vulnerabilities.${severity}:${valueShape(count)}`; + } + } + return undefined; +} + +function transportCode(report: Record, stderr: string): string | undefined { + const error = + typeof report.error === "object" && report.error !== null && !Array.isArray(report.error) + ? (report.error as Record) + : {}; + const evidence = [report.message, error.code, error.summary, error.detail, stderr] + .filter((value): value is string => typeof value === "string") + .join("\n"); + return TRANSIENT_TRANSPORT_CODES.find((code) => + new RegExp(`(?:^|[^A-Z0-9_])${code}(?:$|[^A-Z0-9_])`, "u").test(evidence), + ); +} + +function responseEvidence( + result: Readonly<{ status: number | null; stdout: string }>, + fields: readonly string[], +): string { + const status = result.status === null ? "null" : String(result.status); + return [ + `exit=${status}`, + `stdout-bytes=${Buffer.byteLength(result.stdout)}`, + `stdout-sha256=${sha256(result.stdout)}`, + ...fields, + ].join(" "); +} + +/** Classify one npm response without retaining payload text or unbounded field names. */ +export function classifyNpmAuditResponse(result: { status: number | null; stderr: string; stdout: string; -}): Record { - if (!result.stdout.trim()) throw new Error(`npm audit did not produce JSON: ${result.stderr}`); - let report: Record; - try { - report = JSON.parse(result.stdout) as Record; - } catch (error) { - throw new Error(`npm audit returned invalid JSON: ${String(error)}`); +}): NpmAuditResponseClassification { + if (!result.stdout.trim()) { + return { + failure: { + diagnostic: responseEvidence(result, ["condition=empty-output"]), + reason: "empty-output", + retryable: true, + }, + }; } - let counts: Record; + + let value: unknown; try { - counts = vulnerabilityCounts(report); - } catch (error) { - const detail = report.error === undefined ? result.stderr : JSON.stringify(report.error); - throw new Error( - `npm audit failed without a complete vulnerability report: ${error instanceof Error ? error.message : String(error)}${detail ? `; ${detail}` : ""}`, - ); + value = JSON.parse(result.stdout); + } catch { + return { + failure: { + diagnostic: responseEvidence(result, ["condition=invalid-json"]), + reason: "invalid-json", + retryable: true, + }, + }; } + + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { + failure: { + diagnostic: responseEvidence(result, [ + "condition=incomplete-report", + `required-field=report:${valueShape(value)}`, + ]), + reason: "incomplete-report", + retryable: false, + }, + }; + } + const report = value as Record; + const invalidField = firstInvalidAuditField(report); + if (report.error !== undefined) { + const code = transportCode(report, result.stderr); + const reason = code ? "registry-network-error" : "npm-error-document"; + return { + failure: { + diagnostic: responseEvidence(result, [ + `condition=${reason}`, + ...(code ? [`transport=${code}`] : []), + ...(invalidField ? [`required-field=${invalidField}`] : []), + ]), + reason, + retryable: code !== undefined, + }, + }; + } + if (invalidField) { + return { + failure: { + diagnostic: responseEvidence(result, [ + "condition=incomplete-report", + `required-field=${invalidField}`, + ]), + reason: "incomplete-report", + retryable: false, + }, + }; + } + + const counts = vulnerabilityCounts(report); const findingCount = SEVERITIES.reduce((total, severity) => total + counts[severity], 0); - if ( - report.error !== undefined || - result.status === null || - result.status > 1 || - (result.status !== 0 && findingCount === 0) - ) { - const detail = report.error === undefined ? result.stderr : JSON.stringify(report.error); + if (result.status === null || result.status > 1 || (result.status !== 0 && findingCount === 0)) { + return { + failure: { + diagnostic: responseEvidence(result, ["condition=invalid-exit-status"]), + reason: "invalid-exit-status", + retryable: false, + }, + }; + } + return { report }; +} + +export function parseAuditReport(result: { + status: number | null; + stderr: string; + stdout: string; +}): Record { + const classified = classifyNpmAuditResponse(result); + if ("failure" in classified) { throw new Error( - `npm audit failed without vulnerability findings${detail ? `: ${detail}` : ""}`, + `npm audit response rejected (reason=${classified.failure.reason}; ${classified.failure.diagnostic})`, ); } - return report; + return classified.report; } function waitSynchronously(delayMs: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs); } -function npmAuditRetryReason(result: NpmAuditCommandResult): NpmAuditRetryReason { - if (result.error) return "timeout"; - if (!result.stdout.trim()) return "empty-output"; - try { - JSON.parse(result.stdout); - } catch { - return "invalid-json"; - } - return "incomplete-report"; -} - export function runNpmAuditWithRetry( input: Readonly<{ run: () => NpmAuditCommandResult; @@ -334,6 +481,7 @@ export function runNpmAuditWithRetry( warn?: (message: string) => void; }>, ): Readonly<{ + classification?: NpmAuditFailureClassification; failure?: Error; report?: Record; result: NpmAuditCommandResult; @@ -342,6 +490,7 @@ export function runNpmAuditWithRetry( const warn = input.warn ?? console.warn; const attemptCount = NPM_AUDIT_RETRY_DELAYS_MS.length + 1; let lastResult: NpmAuditCommandResult | undefined; + let lastFailure: NpmAuditFailureClassification | undefined; for (let attempt = 1; attempt <= attemptCount; attempt += 1) { const result = input.run(); @@ -349,25 +498,42 @@ export function runNpmAuditWithRetry( throw result.error; } lastResult = result; - try { - if (result.error) { - throw new Error(`npm audit exceeded its ${NPM_AUDIT_ATTEMPT_TIMEOUT_MS} ms timeout`); - } - return { report: parseAuditReport(result), result }; - } catch { - const delayMs = NPM_AUDIT_RETRY_DELAYS_MS[attempt - 1]; - if (delayMs === undefined) break; - warn( - `npm audit scan incomplete on attempt ${attempt}/${attemptCount}; retrying in ${delayMs} ms (reason=${npmAuditRetryReason(result)})`, - ); - wait(delayMs); + const classified: NpmAuditResponseClassification = result.error + ? { + failure: { + diagnostic: responseEvidence(result, [ + `condition=timeout timeout-ms=${NPM_AUDIT_ATTEMPT_TIMEOUT_MS}`, + ]), + reason: "timeout", + retryable: true, + }, + } + : classifyNpmAuditResponse(result); + if ("report" in classified) return { report: classified.report, result }; + lastFailure = classified.failure; + if (!lastFailure.retryable) { + return { + classification: lastFailure, + failure: new Error( + `npm audit scan failed closed on attempt ${attempt}/${attemptCount} without retry (reason=${lastFailure.reason}; ${lastFailure.diagnostic})`, + ), + result, + }; } + const delayMs = NPM_AUDIT_RETRY_DELAYS_MS[attempt - 1]; + if (delayMs === undefined) break; + warn( + `npm audit scan failed on attempt ${attempt}/${attemptCount}; retrying in ${delayMs} ms (reason=${lastFailure.reason}; ${lastFailure.diagnostic})`, + ); + wait(delayMs); } - if (!lastResult) throw new Error("npm audit retry loop completed without running the scanner"); + if (!lastResult || !lastFailure) + throw new Error("npm audit retry loop completed without running the scanner"); return { + classification: lastFailure, failure: new Error( - `npm audit scan remained incomplete after ${attemptCount} attempts (reason=${npmAuditRetryReason(lastResult)})`, + `npm audit scan failed after ${attemptCount} attempts (reason=${lastFailure.reason}; ${lastFailure.diagnostic})`, ), result: lastResult, }; @@ -906,7 +1072,20 @@ export function runReviewedNpmAudit( : undefined); const auditFailure = audit.failure; const report = audit.report ?? {}; - if (options.reportFile) fs.writeFileSync(options.reportFile, audit.result.stdout); + if (options.reportFile) { + const retainedReport = audit.classification + ? `${JSON.stringify( + { + schemaVersion: 1, + status: "failed", + failure: audit.classification, + }, + null, + 2, + )}\n` + : audit.result.stdout; + fs.writeFileSync(options.reportFile, retainedReport); + } if (options.provenance && options.reportFile) { const provenance = buildAuditProvenance({ cache: cacheEvidence, diff --git a/scripts/lib/verify-mcporter-audit.sh b/scripts/lib/verify-mcporter-audit.sh new file mode 100755 index 00000000000..390c58497e5 --- /dev/null +++ b/scripts/lib/verify-mcporter-audit.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +secret_root="${NEMOCLAW_MCPORTER_AUDIT_SECRET_ROOT:-/run/secrets}" +seed_root="${NEMOCLAW_MCPORTER_AUDIT_SEED_ROOT:-/run/nemoclaw-mcporter-audit-cache}" +secret_receipt="$secret_root/nemoclaw-mcporter-audit-receipt" +secret_raw_report="$secret_root/nemoclaw-mcporter-audit-raw-report" +seed_audit="$seed_root/reviewed-npm-audit" +receipt="" +raw_report="" +receipt_sha256="" + +if [[ -e "$secret_receipt" || -L "$secret_receipt" || -e "$secret_raw_report" || -L "$secret_raw_report" || -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]]; then + if [[ ! -f "$secret_receipt" || -L "$secret_receipt" || ! -f "$secret_raw_report" || -L "$secret_raw_report" ]] \ + || ! printf '%s' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}'; then + echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2 + exit 1 + fi + receipt="$secret_receipt" + raw_report="$secret_raw_report" + receipt_sha256="$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" +elif [[ -e "$seed_audit" || -L "$seed_audit" ]]; then + [[ -d "$seed_audit" && ! -L "$seed_audit" && + -f "$seed_audit/mcporter-runtime.receipt.json" && ! -L "$seed_audit/mcporter-runtime.receipt.json" && + -f "$seed_audit/mcporter-runtime.raw.json" && ! -L "$seed_audit/mcporter-runtime.raw.json" && + -f "$seed_audit/mcporter-runtime.receipt.sha256" && ! -L "$seed_audit/mcporter-runtime.receipt.sha256" ]] \ + || { + echo "ERROR: seed-cached mcporter audit evidence is incomplete" >&2 + exit 1 + } + receipt="$seed_audit/mcporter-runtime.receipt.json" + raw_report="$seed_audit/mcporter-runtime.raw.json" + read -r receipt_sha256 <"$seed_audit/mcporter-runtime.receipt.sha256" + printf '%s' "$receipt_sha256" | grep -qxE '[0-9a-f]{64}' || { + echo "ERROR: seed-cached mcporter audit receipt SHA-256 is invalid" >&2 + exit 1 + } +fi + +if [[ -z "$receipt" ]]; then + exec node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ + --directory /usr/local/lib/nemoclaw/mcporter-runtime \ + --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high +fi + +printf '%s %s\n' "$receipt_sha256" "$receipt" | sha256sum --check --status - || { + echo "ERROR: cached mcporter audit receipt hash does not match" >&2 + exit 1 +} +exec node --experimental-strip-types /scripts/lib/npm-audit-receipt.mts \ + --receipt "$receipt" \ + --package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ + --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ + --raw-report "$raw_report" --exceptions /scripts/npm-audit-exceptions.json \ + --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ + --registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index e0e96f7a55b..41ff15a5ba1 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -461,6 +461,10 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "lib", "openclaw-npm-remediation.mts"), path.join(stagedScriptsDir, "lib", "openclaw-npm-remediation.mts"), ); + fs.copyFileSync( + path.join(rootDir, "scripts", "lib", "verify-mcporter-audit.sh"), + path.join(stagedScriptsDir, "lib", "verify-mcporter-audit.sh"), + ); normalizeReadModesForDockerCopy(stagedScriptsDir); return { buildCtx, stagedDockerfile }; diff --git a/test/automation/releases/npm-audit-receipt.test.ts b/test/automation/releases/npm-audit-receipt.test.ts index 410ab8559bd..7cec1812667 100644 --- a/test/automation/releases/npm-audit-receipt.test.ts +++ b/test/automation/releases/npm-audit-receipt.test.ts @@ -152,6 +152,12 @@ describe("reviewed npm audit receipt", () => { packageLock: "changed", }), ).toThrow(/packageLockSha256/); + expect(() => + parseAndVerifyAuditReceipt(canonicalAuditReceipt(receipt()), { + ...inputs, + rawResponse: `${inputs.rawResponse}\n`, + }), + ).toThrow(/rawResponseSha256/); }); it.each([ diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 8d12e2b4b9d..a1f5c260ef0 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -411,7 +411,7 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { "parseable npm error JSON", JSON.stringify({ error: { code: "ECONNREFUSED", summary: "registry unreachable" } }), 1, - /incomplete-report/, + /registry-network-error/, ], ["missing vulnerability metadata", JSON.stringify({}), 0, /incomplete-report/], [ diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index fbbd3eacf40..ed8dbd9f317 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -15,6 +15,7 @@ import { assertExceptionGraphs, buildAuditCacheInput, buildAuditProvenance, + classifyNpmAuditResponse, deriveAuditEndpoints, evaluateAuditPolicy, exceedsAuditThreshold, @@ -30,6 +31,10 @@ import { vulnerabilityCounts, } from "../../../scripts/lib/reviewed-npm-audit.mts"; import { reviewedNpmAuditWorkflowDeadlines } from "../../helpers/reviewed-npm-audit-workflow"; +import { + parseAuditConfig, + selectLockedGraph, +} from "../../../scripts/audit-reviewed-npm-graph.mts"; const REPO_ROOT = path.join(import.meta.dirname, "../../.."); const CONFIG = JSON.parse( @@ -128,6 +133,21 @@ function exceptionPolicy( } describe("reviewed npm audit gate", () => { + it("selects only a configured locked graph for dedicated evidence production (#11088)", () => { + const auditConfig = parseAuditConfig( + fs.readFileSync(path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"), "utf8"), + ); + + expect(selectLockedGraph(auditConfig, "mcporter-runtime")).toMatchObject({ + graph: { id: "mcporter-runtime" }, + index: auditConfig.lockedGraphs.findIndex(({ id }) => id === "mcporter-runtime"), + }); + expect(selectLockedGraph(auditConfig, undefined)).toBeUndefined(); + expect(() => selectLockedGraph(auditConfig, "unknown-graph")).toThrow( + "reviewed npm audit locked graph is not configured", + ); + }); + it("removes the checked-in brace-expansion exception after remediation (#8116)", () => { expect(CHECKED_IN_POLICY).toEqual(EMPTY_POLICY); }); @@ -154,6 +174,19 @@ describe("reviewed npm audit gate", () => { ); }); + it("accepts a complete clean npm audit report", () => { + const report = { + vulnerabilities: {}, + metadata: { + vulnerabilities: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, + }, + }; + + expect( + classifyNpmAuditResponse({ status: 0, stderr: "", stdout: JSON.stringify(report) }), + ).toEqual({ report }); + }); + it("rejects a parseable npm transport failure instead of treating it as clean", () => { expect(() => parseAuditReport({ @@ -163,7 +196,33 @@ describe("reviewed npm audit gate", () => { error: { code: "ECONNREFUSED", summary: "request to registry failed" }, }), }), - ).toThrow(/ECONNREFUSED/); + ).toThrow(/registry-network-error.*ECONNREFUSED/); + }); + + it("classifies npm 11.18.0's observed registry error document without exposing its message (#11088)", () => { + const secret = "https://audit-user:registry-secret@registry.example/private"; + const classified = classifyNpmAuditResponse({ + status: 1, + stderr: `authorization: Bearer stderr-secret for ${secret}`, + stdout: JSON.stringify({ + message: `request to ${secret} failed, reason: connect ECONNREFUSED 127.0.0.1:9`, + error: { summary: "", detail: "" }, + }), + }); + + expect(classified).toEqual({ + failure: { + diagnostic: expect.stringMatching( + /^exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=ECONNREFUSED required-field=metadata:missing$/, + ), + reason: "registry-network-error", + retryable: true, + }, + }); + expect(JSON.stringify(classified)).not.toContain("audit-user"); + expect(JSON.stringify(classified)).not.toContain("registry-secret"); + expect(JSON.stringify(classified)).not.toContain("stderr-secret"); + expect(JSON.stringify(classified)).not.toContain("registry.example"); }); it.each([ @@ -175,7 +234,20 @@ describe("reviewed npm audit gate", () => { ])("rejects %s", (_label, report) => { expect(() => parseAuditReport({ status: 0, stderr: "", stdout: JSON.stringify(report) }), - ).toThrow(/vulnerability report|vulnerability count/); + ).toThrow(/incomplete-report.*required-field=metadata/); + }); + + it.each([ + ["empty output", "", "empty-output"], + ["truncated JSON", '{"metadata":{"vulnerabilities":', "invalid-json"], + ])("classifies %s for the bounded retry policy", (_label, stdout, reason) => { + expect(classifyNpmAuditResponse({ status: 1, stderr: "", stdout })).toEqual({ + failure: { + diagnostic: expect.stringContaining(`condition=${reason}`), + reason, + retryable: true, + }, + }); }); it("retries scan-incomplete npm responses with bounded backoff", () => { @@ -203,7 +275,9 @@ describe("reviewed npm audit gate", () => { expect(attempt).toBe(2); expect(delays).toEqual([1_000]); expect(warnings).toEqual([ - "npm audit scan incomplete on attempt 1/2; retrying in 1000 ms (reason=empty-output)", + expect.stringMatching( + /^npm audit scan failed on attempt 1\/2; retrying in 1000 ms \(reason=empty-output; exit=1 stdout-bytes=0 stdout-sha256=[a-f0-9]{64} condition=empty-output\)$/, + ), ]); const warningOutput = warnings.join("\n"); expect(warningOutput).not.toContain("audit-user"); @@ -331,7 +405,7 @@ describe("reviewed npm audit gate", () => { expect(warnings).toEqual([]); }); - it("fails closed after the scan-incomplete retry budget is exhausted", () => { + it("fails closed after the bounded registry-network retry budget is exhausted (#11088)", () => { const delays: number[] = []; let attempts = 0; @@ -342,6 +416,7 @@ describe("reviewed npm audit gate", () => { status: 1, stderr: "registry-token=terminal-stderr-secret", stdout: JSON.stringify({ + message: "request failed with EAI_AGAIN and terminal-message-secret", error: { summary: "registry-token=terminal-summary-secret", detail: "authorization: bearer terminal-detail-secret", @@ -356,12 +431,41 @@ describe("reviewed npm audit gate", () => { expect(attempts).toBe(2); expect(delays).toEqual([1_000]); expect(audit.report).toBeUndefined(); - expect(audit.failure?.message).toBe( - "npm audit scan remained incomplete after 2 attempts (reason=incomplete-report)", + expect(audit.failure?.message).toMatch( + /^npm audit scan failed after 2 attempts \(reason=registry-network-error; exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=EAI_AGAIN required-field=metadata:missing\)$/, ); expect(audit.failure?.message).not.toContain("terminal-stderr-secret"); expect(audit.failure?.message).not.toContain("terminal-summary-secret"); expect(audit.failure?.message).not.toContain("terminal-detail-secret"); + expect(audit.failure?.message).not.toContain("terminal-message-secret"); + }); + + it.each([ + ["missing metadata", {}], + [ + "malformed severity count", + { metadata: { vulnerabilities: { info: 0, low: 0, moderate: 0, high: [], critical: 0 } } }, + ], + ["unknown npm error document", { error: { summary: "unsupported response" } }], + ])("does not retry deterministic %s responses (#11088)", (_label, report) => { + const delays: number[] = []; + const warnings: string[] = []; + let attempts = 0; + + const audit = runNpmAuditWithRetry({ + run: () => { + attempts += 1; + return { status: 1, stderr: "", stdout: JSON.stringify(report) }; + }, + wait: (delayMs) => delays.push(delayMs), + warn: (message) => warnings.push(message), + }); + + expect(attempts).toBe(1); + expect(delays).toEqual([]); + expect(warnings).toEqual([]); + expect(audit.report).toBeUndefined(); + expect(audit.failure?.message).toMatch(/failed closed on attempt 1\/2 without retry/); }); it("accepts one exact blocking advisory and its propagated meta-vulnerability", () => { @@ -714,7 +818,7 @@ describe("reviewed npm audit provenance", () => { [ "#!/bin/sh", 'test "$1" = "audit" && {', - ' echo \'{"error":{"code":"ECONNREFUSED","summary":"registry unreachable"}}\'', + ' echo \'{"message":"request to https://audit-user:secret-token@registry.example failed: ECONNREFUSED","error":{"code":"ECONNREFUSED","summary":"registry unreachable"}}\'', " exit 1", "}", "exit 7", @@ -738,18 +842,25 @@ describe("reviewed npm audit provenance", () => { reportFile: reportPath, threshold: "high", }), - ).toThrow("npm audit scan remained incomplete after 2 attempts (reason=incomplete-report)"); + ).toThrow(/failed after 2 attempts.*registry-network-error.*transport=ECONNREFUSED/); const sidecar = JSON.parse( fs.readFileSync(path.join(tempRoot, "graph.provenance.json"), "utf-8"), ) as Record; - expect(sidecar.failure).toBe( - "npm audit scan remained incomplete after 2 attempts (reason=incomplete-report)", + expect(sidecar.failure).toMatch( + /^npm audit scan failed after 2 attempts \(reason=registry-network-error; exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=ECONNREFUSED required-field=metadata:missing\)$/, ); - expect(sidecar.failure).not.toContain("ECONNREFUSED"); + expect(sidecar.failure).toContain("ECONNREFUSED"); expect(sidecar.failure).not.toContain("registry unreachable"); expect(sidecar.advisoryIds).toEqual([]); expect(sidecar.rawReportPath).toBe("graph.json"); expect(sidecar.registry).toEqual(deriveAuditEndpoints("https://registry.yarnpkg.com")); + const retainedFailure = fs.readFileSync(reportPath, "utf8"); + expect(retainedFailure).toMatch(/"reason": "registry-network-error"/); + expect(retainedFailure).toContain("transport=ECONNREFUSED"); + expect(retainedFailure).not.toContain("audit-user"); + expect(retainedFailure).not.toContain("secret-token"); + expect(retainedFailure).not.toContain("registry.example"); + expect(retainedFailure).not.toContain("registry unreachable"); } finally { process.env.PATH = originalPath; fs.rmSync(tempRoot, { recursive: true, force: true }); diff --git a/test/install/materialize-locked-npm-cache-seed.test.ts b/test/install/materialize-locked-npm-cache-seed.test.ts index ee2bdd41f6d..08072b4e98c 100644 --- a/test/install/materialize-locked-npm-cache-seed.test.ts +++ b/test/install/materialize-locked-npm-cache-seed.test.ts @@ -6,11 +6,13 @@ import { appendFileSync, chmodSync, existsSync, + mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, + statSync, unlinkSync, writeFileSync, } from "node:fs"; @@ -71,6 +73,28 @@ function writeLock(root: string, archives: readonly LockedArchive[]): string { return lockfile; } +function writeReviewedAuditEvidence(seed: string): { + directory: string; + rawReport: Buffer; + receipt: Buffer; +} { + const directory = path.join(seed, "reviewed-npm-audit"); + const rawReport = Buffer.from( + '{"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n', + ); + const receipt = Buffer.from( + `${JSON.stringify({ rawResponseSha256: crypto.createHash("sha256").update(rawReport).digest("hex") })}\n`, + ); + mkdirSync(directory); + writeFileSync(path.join(directory, "mcporter-runtime.raw.json"), rawReport); + writeFileSync(path.join(directory, "mcporter-runtime.receipt.json"), receipt); + writeFileSync( + path.join(directory, "mcporter-runtime.receipt.sha256"), + `${crypto.createHash("sha256").update(receipt).digest("hex")}\n`, + ); + return { directory, rawReport, receipt }; +} + let testRoot = ""; beforeEach(() => { @@ -262,6 +286,77 @@ describe("locked npm cache seed materialization", () => { ).rejects.toThrow("npm cache seed directory contains missing or unexpected files"); }); + it("copies reviewed npm audit evidence only after receipt and raw-report integrity checks", async () => { + const alpha = archive("alpha", "alpha archive"); + const lockfile = writeLock(testRoot, [alpha.locked]); + const seed = path.join(testRoot, "seed"); + const copied = path.join(testRoot, "copied"); + await materializeLockedNpmCacheSeed({ + downloadArchive: async () => alpha.bytes, + lockfile, + output: seed, + target: TARGET, + }); + const evidence = writeReviewedAuditEvidence(seed); + + await verifyAndCopyLockedNpmCacheSeed({ lockfile, output: copied, seed, target: TARGET }); + + const copiedEvidence = path.join(copied, "reviewed-npm-audit"); + expect(readFileSync(path.join(copiedEvidence, "mcporter-runtime.raw.json"))).toEqual( + evidence.rawReport, + ); + expect(readFileSync(path.join(copiedEvidence, "mcporter-runtime.receipt.json"))).toEqual( + evidence.receipt, + ); + expect(statSync(copiedEvidence).mode & 0o777).toBe(0o700); + expect(statSync(path.join(copiedEvidence, "mcporter-runtime.raw.json")).mode & 0o777).toBe( + 0o400, + ); + }); + + it.each([ + { + expected: "reviewed npm audit raw report hash does not match its receipt", + mutate: (directory: string) => + appendFileSync(path.join(directory, "mcporter-runtime.raw.json"), "tampered"), + name: "raw report", + }, + { + expected: "reviewed npm audit receipt hash does not match", + mutate: (directory: string) => + appendFileSync(path.join(directory, "mcporter-runtime.receipt.json"), "tampered"), + name: "receipt", + }, + { + expected: "reviewed npm audit receipt hash does not match", + mutate: (directory: string) => + writeFileSync(path.join(directory, "mcporter-runtime.receipt.sha256"), `${"0".repeat(64)}\n`), + name: "receipt hash", + }, + { + expected: "reviewed npm audit receipt hash must be one regular non-symlink file", + mutate: (directory: string) => + writeFileSync(path.join(directory, "mcporter-runtime.receipt.sha256"), "x".repeat(66)), + name: "oversized receipt hash", + }, + ])("rejects tampered reviewed npm audit $name evidence", async ({ expected, mutate }) => { + const alpha = archive("alpha", "alpha archive"); + const lockfile = writeLock(testRoot, [alpha.locked]); + const seed = path.join(testRoot, "seed"); + await materializeLockedNpmCacheSeed({ + downloadArchive: async () => alpha.bytes, + lockfile, + output: seed, + target: TARGET, + }); + const { directory } = writeReviewedAuditEvidence(seed); + mutate(directory); + + await expect( + verifyAndCopyLockedNpmCacheSeed({ lockfile, seed, target: TARGET }), + ).rejects.toThrow(expected); + }); + it.skipIf(process.platform === "win32")( "rejects a lock-pinned archive replaced with a symlink", async () => { diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index 749b4641ce2..5bf2f266873 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -51,6 +51,20 @@ printf '%s\n' "$*" >>"$NEMOCLAW_TEST_DOCKER_LOG" case "$*" in "buildx imagetools inspect "*) printf '{}\n' ;; "buildx build "*) + if [[ "$*" == *"--target protected-mcporter-audit-evidence"* ]]; then + output_spec="" + previous="" + for argument in "$@"; do + if [[ "$previous" == "--output" ]]; then output_spec="$argument"; fi + previous="$argument" + done + destination="\${output_spec#type=local,dest=}" + [[ -n "$destination" && "$destination" != "$output_spec" ]] + mkdir -p "$destination" + printf '{"result":"pass"}\n' >"$destination/mcporter-runtime.receipt.json" + printf '{"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n' >"$destination/mcporter-runtime.raw.json" + exit 0 + fi build_count=0 if [[ -f "$NEMOCLAW_TEST_DOCKER_BUILD_COUNT" ]]; then read -r build_count <"$NEMOCLAW_TEST_DOCKER_BUILD_COUNT" @@ -156,6 +170,14 @@ function completeImportedCache(cacheRoot: string): void { ); mkdirSync(path.join(cacheRoot, "messaging-npm-cache-seed")); writeFileSync(path.join(cacheRoot, "messaging-npm-cache-seed", "manifest.json"), "{}\n", "utf8"); + const auditDirectory = path.join(cacheRoot, "reviewed-npm-audit"); + mkdirSync(auditDirectory); + writeFileSync(path.join(auditDirectory, "mcporter-runtime.receipt.json"), '{"result":"pass"}\n'); + writeFileSync( + path.join(auditDirectory, "mcporter-runtime.raw.json"), + '{"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n', + ); + writeFileSync(path.join(auditDirectory, "mcporter-runtime.receipt.sha256"), `${DIGEST}\n`); } function completeSourceBoundary(sourceRoot: string): void { @@ -205,7 +227,20 @@ function completeSourceBoundary(sourceRoot: string): void { function recordedBuildInvocations(): string[] { return readFileSync(dockerLog, "utf8") .split("\n") - .filter((line) => line.startsWith("buildx build ")); + .filter( + (line) => + line.startsWith("buildx build ") && line.includes("io.nvidia.nemoclaw.agent="), + ); +} + +function recordedAuditBuildInvocations(): string[] { + return readFileSync(dockerLog, "utf8") + .split("\n") + .filter( + (line) => + line.startsWith("buildx build ") && + line.includes("--target protected-mcporter-audit-evidence"), + ); } function recordedBuildInvocation(agent: string): string { @@ -346,6 +381,7 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocations()).toHaveLength(3); + expect(recordedAuditBuildInvocations()).toEqual([]); expect({ openclaw: recordedBuildInvocation("openclaw"), @@ -384,6 +420,15 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(existsSync(cacheRoot)).toBe(true); expect(recordedBuildInvocations()).toHaveLength(3); + expect(recordedAuditBuildInvocations()).toEqual([ + expect.stringContaining( + `--target protected-mcporter-audit-evidence --output type=local,dest=${realpathSync(cacheRoot)}/reviewed-npm-audit`, + ), + ]); + expect(recordedAuditBuildInvocations()[0]).toContain( + `--file ${REPO_ROOT}/Dockerfile.protected-npm-audit`, + ); + expect(recordedAuditBuildInvocations()[0]).not.toContain("--network none"); expect({ openclaw: recordedBuildInvocation("openclaw"), @@ -417,6 +462,39 @@ describe("protected managed-image build-cache boundary", () => { expect(existsSync(path.join(cacheRoot, "messaging-npm-cache-seed", "manifest.json"))).toBe( true, ); + expect(existsSync(path.join(cacheRoot, "reviewed-npm-audit", "mcporter-runtime.raw.json"))).toBe( + true, + ); + expect( + readFileSync( + path.join(cacheRoot, "reviewed-npm-audit", "mcporter-runtime.receipt.sha256"), + "utf8", + ), + ).toBe(`${DIGEST}\n`); + expect( + readFileSync( + path.join( + cacheRoot, + "npm-cache-seed", + "reviewed-npm-audit", + "mcporter-runtime.receipt.sha256", + ), + "utf8", + ), + ).toBe(`${DIGEST}\n`); + expect(recordedBuildInvocation("openclaw")).toContain( + `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(cacheRoot)}/reviewed-npm-audit/mcporter-runtime.receipt.json`, + ); + expect(recordedBuildInvocation("openclaw")).toContain( + `--secret id=nemoclaw-mcporter-audit-raw-report,src=${realpathSync(cacheRoot)}/reviewed-npm-audit/mcporter-runtime.raw.json`, + ); + expect(recordedBuildInvocation("openclaw")).toContain( + `--build-arg NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=${DIGEST}`, + ); + expect(recordedBuildInvocation("hermes")).not.toContain("nemoclaw-mcporter-audit"); + expect(recordedBuildInvocation("langchain-deepagents-code")).not.toContain( + "nemoclaw-mcporter-audit", + ); }); it.each([ @@ -496,6 +574,34 @@ describe("protected managed-image build-cache boundary", () => { expect(existsSync(dockerLog)).toBe(false); }); + it("rejects an imported cache without reviewed mcporter audit evidence (#11088)", () => { + const cacheRoot = path.join(testRoot, "imported-cache"); + completeImportedCache(cacheRoot); + rmSync(path.join(cacheRoot, "reviewed-npm-audit"), { recursive: true }); + + const result = runBuild(REPO_ROOT, ["--cache-from", cacheRoot]); + + expect(result.status, result.stderr).toBe(1); + expect(result.stderr).toContain("cache has no reviewed mcporter audit evidence"); + expect(existsSync(dockerLog)).toBe(false); + }); + + it("rejects a changed reviewed audit receipt before invoking Docker (#11088)", () => { + const cacheRoot = path.join(testRoot, "imported-cache"); + completeImportedCache(cacheRoot); + stubBuildInvocation(); + writeFileSync( + path.join(cacheRoot, "reviewed-npm-audit", "mcporter-runtime.receipt.sha256"), + `${"c".repeat(64)}\n`, + ); + + const result = runBuild(REPO_ROOT, ["--cache-from", cacheRoot]); + + expect(result.status, result.stderr).toBe(1); + expect(result.stderr).toContain("reviewed audit receipt hash does not match"); + expect(existsSync(dockerLog)).toBe(false); + }); + it("imports locked seeds, reuses safe agent caches, and disables RUN network access", () => { const cacheRoot = path.join(testRoot, "imported-cache"); const sourceSeed = path.join(REPO_ROOT, "tools/mcp-tool-discovery-runtime/npm-cache-seed"); @@ -539,6 +645,12 @@ describe("protected managed-image build-cache boundary", () => { ), }); expect(recordedBuildInvocation("openclaw").split(" ")).toContain("--no-cache"); + expect(recordedBuildInvocation("openclaw")).toContain( + `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(cacheRoot)}/reviewed-npm-audit/mcporter-runtime.receipt.json`, + ); + expect(recordedBuildInvocation("openclaw")).toContain( + `--build-arg NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=${DIGEST}`, + ); expect(recordedBuildInvocation("hermes").split(" ")).not.toContain("--no-cache"); expect(recordedBuildInvocation("langchain-deepagents-code").split(" ")).not.toContain( "--no-cache", diff --git a/test/runtime/sandbox/sandbox-build-context.test.ts b/test/runtime/sandbox/sandbox-build-context.test.ts index b4dc2a1a1e5..0cb2cf451c4 100644 --- a/test/runtime/sandbox/sandbox-build-context.test.ts +++ b/test/runtime/sandbox/sandbox-build-context.test.ts @@ -291,6 +291,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "lib", "reviewed-npm-audit.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "npm-audit-receipt.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "openclaw-npm-remediation.mts"), "fixture\n", 0o700); + writeFixture(path.join("scripts", "lib", "verify-mcporter-audit.sh"), "fixture\n", 0o700); fs.chmodSync(path.join(sourceRoot, "scripts"), 0o700); fs.chmodSync(path.join(sourceRoot, "scripts", "lib"), 0o700); } @@ -549,6 +550,11 @@ describe("sandbox build context staging", () => { ); expect((fs.statSync(stagedFile).mode & 0o777).toString(8)).toBe("644"); } + for (const relativePath of [path.join("scripts", "lib", "verify-mcporter-audit.sh")]) { + expect(fs.readFileSync(path.join(buildCtx, relativePath), "utf8")).toBe( + fs.readFileSync(path.join(sourceRoot, relativePath), "utf8"), + ); + } } it("normalizes restrictive and group-writable modes for Docker COPY", () => { diff --git a/test/security/mcporter-audit-evidence.test.ts b/test/security/mcporter-audit-evidence.test.ts new file mode 100644 index 00000000000..c9f58e97768 --- /dev/null +++ b/test/security/mcporter-audit-evidence.test.ts @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const REPO_ROOT = path.join(import.meta.dirname, "../.."); +const SCRIPT = path.join(REPO_ROOT, "scripts", "lib", "verify-mcporter-audit.sh"); + +let root = ""; +let seedRoot = ""; +let secretRoot = ""; +let nodeLog = ""; + +function runGate(receiptSha256 = "") { + return spawnSync("bash", [SCRIPT], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${path.join(root, "bin")}${path.delimiter}${process.env.PATH}`, + NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: receiptSha256, + NEMOCLAW_MCPORTER_AUDIT_SECRET_ROOT: secretRoot, + NEMOCLAW_MCPORTER_AUDIT_SEED_ROOT: seedRoot, + NEMOCLAW_TEST_NODE_LOG: nodeLog, + }, + }); +} + +function writeEvidence(directory: string, receiptName: string, rawName: string): string { + fs.mkdirSync(directory, { recursive: true }); + const receipt = Buffer.from('{"receipt":"fixture"}\n'); + fs.writeFileSync(path.join(directory, receiptName), receipt); + fs.writeFileSync(path.join(directory, rawName), '{"vulnerabilities":{}}\n'); + return crypto.createHash("sha256").update(receipt).digest("hex"); +} + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcporter-audit-evidence-")); + seedRoot = path.join(root, "seed"); + secretRoot = path.join(root, "secrets"); + nodeLog = path.join(root, "node.log"); + fs.mkdirSync(path.join(root, "bin")); + fs.mkdirSync(seedRoot); + fs.mkdirSync(secretRoot); + fs.writeFileSync( + path.join(root, "bin", "node"), + '#!/usr/bin/env bash\nprintf \'%s\\n\' "$*" >"$NEMOCLAW_TEST_NODE_LOG"\n', + { mode: 0o755 }, + ); +}); + +afterEach(() => { + fs.rmSync(root, { force: true, recursive: true }); +}); + +describe("mcporter reviewed audit evidence gate", () => { + it("runs the live fail-closed audit when no receipt source exists", () => { + const result = runGate(); + + expect(result.status, result.stderr).toBe(0); + expect(fs.readFileSync(nodeLog, "utf8")).toContain( + "/scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime", + ); + }); + + it("verifies complete seed-carried evidence before invoking the receipt gate", () => { + const directory = path.join(seedRoot, "reviewed-npm-audit"); + const receiptSha256 = writeEvidence( + directory, + "mcporter-runtime.receipt.json", + "mcporter-runtime.raw.json", + ); + fs.writeFileSync(path.join(directory, "mcporter-runtime.receipt.sha256"), `${receiptSha256}\n`); + + const result = runGate(); + + expect(result.status, result.stderr).toBe(0); + const invocation = fs.readFileSync(nodeLog, "utf8"); + expect(invocation).toContain("/scripts/lib/npm-audit-receipt.mts --receipt"); + expect(invocation).toContain(path.join(directory, "mcporter-runtime.receipt.json")); + expect(invocation).toContain(path.join(directory, "mcporter-runtime.raw.json")); + }); + + it("rejects incomplete or hash-mismatched seed evidence without a live fallback", () => { + const directory = path.join(seedRoot, "reviewed-npm-audit"); + const receiptSha256 = writeEvidence( + directory, + "mcporter-runtime.receipt.json", + "mcporter-runtime.raw.json", + ); + + const incomplete = runGate(); + expect(incomplete.status).not.toBe(0); + expect(incomplete.stderr).toContain("seed-cached mcporter audit evidence is incomplete"); + expect(fs.existsSync(nodeLog)).toBe(false); + + fs.writeFileSync(path.join(directory, "mcporter-runtime.receipt.sha256"), `${receiptSha256}\n`); + fs.appendFileSync(path.join(directory, "mcporter-runtime.receipt.json"), "tampered"); + const tampered = runGate(); + expect(tampered.status).not.toBe(0); + expect(tampered.stderr).toContain("cached mcporter audit receipt hash does not match"); + expect(fs.existsSync(nodeLog)).toBe(false); + }); + + it("requires paired secret evidence and its exact receipt hash", () => { + const receiptSha256 = writeEvidence( + secretRoot, + "nemoclaw-mcporter-audit-receipt", + "nemoclaw-mcporter-audit-raw-report", + ); + + const result = runGate(receiptSha256); + + expect(result.status, result.stderr).toBe(0); + expect(fs.readFileSync(nodeLog, "utf8")).toContain( + "/scripts/lib/npm-audit-receipt.mts --receipt", + ); + }); +}); diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index e48a3208400..95e7c7b12b4 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -47,6 +47,10 @@ const reviewedAuditDriver = fs.readFileSync( path.join(repoRoot, "scripts", "audit-reviewed-npm-graph.mts"), "utf8", ); +const mcporterAuditHelper = fs.readFileSync( + path.join(repoRoot, "scripts", "lib", "verify-mcporter-audit.sh"), + "utf8", +); function extractIntegrityGate(contents: string): string { const startMarker = 'MCPORTER_EXPECTED_INTEGRITY=""'; @@ -189,17 +193,18 @@ describe("mcporter image supply-chain controls", () => { expect(unpinned.stdout).not.toContain("gate-passed"); }); - it.each(dockerfiles)("audits the committed dependency graph in $name", ({ contents }) => { - const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); - const auditReceiptInvocation = extractAuditReceiptInvocation(contents); - expect(contents).toContain( + it.each(dockerfiles)("audits the committed dependency graph in $name", ({ name, contents }) => { + const auditContents = name === "Dockerfile" ? `${contents}\n${mcporterAuditHelper}` : contents; + const flattenedContents = auditContents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + const auditReceiptInvocation = extractAuditReceiptInvocation(auditContents); + expect(auditContents).toContain( "COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/", ); expect( flattenedContents.includes( "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/bundled-npm-package.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/openclaw-npm-remediation.mts /scripts/lib/", ) || - contents.includes( + auditContents.includes( "COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts", ), ).toBe(true); @@ -224,7 +229,7 @@ describe("mcporter image supply-chain controls", () => { ); expect(expectedReviewedNpmVersion).toMatch(/^[0-9]+\.[0-9]+\.[0-9]+$/); expect(auditReceiptInvocation).not.toContain("--npm-version"); - expect(contents).not.toContain("--raw-copy"); + expect(auditContents).not.toContain("--raw-copy"); expect(auditReceiptInvocation).not.toMatch(/\bnpm\s+--version\b/); expect(auditReceiptInvocation).not.toMatch(/\$\(|`/); expect(contents).not.toContain(`${runtimePrefix} audit --omit=dev --audit-level=low`); @@ -235,6 +240,35 @@ describe("mcporter image supply-chain controls", () => { expect(contents).toContain("StreamableHTTPServerTransport"); }); + it("carries a networked reviewed audit into the offline protected OpenClaw build", () => { + const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); + const producer = fs.readFileSync( + path.join(repoRoot, "Dockerfile.protected-npm-audit"), + "utf8", + ); + const flattenedProducer = producer.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + + expect(producer).toContain( + `FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c AS protected-mcporter-audit`, + ); + expect(flattenedProducer).toContain( + "NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH=mcporter-runtime NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR=artifacts/reviewed-npm-audit", + ); + expect(producer).toContain("FROM scratch AS protected-mcporter-audit-evidence"); + expect(contents).toContain("FROM scratch AS protected-mcporter-audit-cache"); + expect(contents).toContain( + "--mount=type=bind,from=protected-mcporter-audit-cache,source=/seed,target=/run/nemoclaw-mcporter-audit-cache", + ); + expect(contents).toContain("bash /scripts/lib/verify-mcporter-audit.sh"); + expect(mcporterAuditHelper).toContain("seed-cached mcporter audit evidence is incomplete"); + expect(mcporterAuditHelper).toContain( + "seed-cached mcporter audit receipt SHA-256 is invalid", + ); + expect(mcporterAuditHelper.replace(/\\\s*\n/g, " ").replace(/\s+/g, " ")).toContain( + `printf '%s %s\\n' "$receipt_sha256" "$receipt" | sha256sum --check --status -`, + ); + }); + it("copies the cached base-image audit report only after receipt verification succeeds", () => { const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile.base"), "utf8"); const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); From ddca63d057495a38a786fe202e5c385394f1fa59 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 7 Sep 2026 15:56:05 +0700 Subject: [PATCH 02/56] refactor: narrow protected audit fix --- Dockerfile | 8 -- .../checks/build-protected-managed-images.sh | 82 +++++------- .../materialize-locked-npm-cache-seed.mts | 100 +------------- scripts/lib/reviewed-npm-audit.mts | 94 +++++-------- scripts/lib/verify-mcporter-audit.sh | 25 +--- .../materialize-locked-npm-cache-seed.test.ts | 95 -------------- ...otected-managed-image-build-script.test.ts | 50 +++---- test/security/mcporter-audit-evidence.test.ts | 124 ------------------ test/security/mcporter-supply-chain.test.ts | 6 +- 9 files changed, 87 insertions(+), 497 deletions(-) delete mode 100644 test/security/mcporter-audit-evidence.test.ts diff --git a/Dockerfile b/Dockerfile index f3b5df01a1f..abcef761735 100644 --- a/Dockerfile +++ b/Dockerfile @@ -600,13 +600,6 @@ COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/ COPY --from=mcp-tool-discovery-runtime /opt/mcp-tool-discovery-runtime/dist/ /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime/ -# Keep optional audit evidence in an uncommitted mount source. The checked-in -# seed has no reviewed-npm-audit directory, so ordinary builds still perform a -# live audit. Protected builders import validated evidence into the seed before -# invoking this Dockerfile with network access disabled. -FROM scratch AS protected-mcporter-audit-cache -COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/ /seed/ - # Stage 3: Runtime image — pull cached base from GHCR # hadolint ignore=DL3006 FROM ${BASE_IMAGE} @@ -831,7 +824,6 @@ RUN command -v codex-acp >/dev/null RUN --network=default \ --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ - --mount=type=bind,from=protected-mcporter-audit-cache,source=/seed,target=/run/nemoclaw-mcporter-audit-cache \ set -eu; \ if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index 746dacb07f6..be132b2ebe1 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -171,6 +171,28 @@ audit_evidence_dir="" audit_receipt="" audit_raw_report="" audit_receipt_sha256="" +validate_audit_evidence() { + local directory="$1" + [[ -d "$directory" && ! -L "$directory" && -z "$(find "$directory" -type l -print -quit)" ]] || { + echo "ERROR: protected managed-image reviewed audit evidence is missing or unsafe" >&2 + exit 1 + } + audit_receipt="$directory/mcporter-runtime.receipt.json" + audit_raw_report="$directory/mcporter-runtime.raw.json" + local hash_file="$directory/mcporter-runtime.receipt.sha256" + [[ -f "$audit_receipt" && -s "$audit_receipt" && -f "$audit_raw_report" && -s "$audit_raw_report" && -f "$hash_file" ]] || { + echo "ERROR: protected managed-image reviewed audit evidence is incomplete" >&2 + exit 1 + } + local recorded_hash + read -r recorded_hash <"$hash_file" + audit_receipt_sha256="$(sha256sum "$audit_receipt" | awk '{print $1}')" + [[ "$recorded_hash" =~ ^[a-f0-9]{64}$ && "$recorded_hash" == "$audit_receipt_sha256" ]] || { + echo "ERROR: protected managed-image reviewed audit receipt hash does not match" >&2 + exit 1 + } +} + if [[ -n "$cache_to" ]]; then audit_evidence_dir="$cache_to/reviewed-npm-audit" docker buildx build \ @@ -181,49 +203,19 @@ if [[ -n "$cache_to" ]]; then --provenance=false \ --sbom=false \ "$source_root" + [[ -f "$audit_evidence_dir/mcporter-runtime.receipt.json" && ! -L "$audit_evidence_dir/mcporter-runtime.receipt.json" ]] || { + echo "ERROR: protected managed-image reviewed audit receipt is missing or unsafe" >&2 + exit 1 + } + sha256sum "$audit_evidence_dir/mcporter-runtime.receipt.json" | awk '{print $1}' \ + >"$audit_evidence_dir/mcporter-runtime.receipt.sha256" + chmod 0400 "$audit_evidence_dir/mcporter-runtime.receipt.sha256" elif [[ -n "$cache_from" ]]; then audit_evidence_dir="$cache_from/reviewed-npm-audit" fi if [[ -n "$audit_evidence_dir" ]]; then - [[ -d "$audit_evidence_dir" && ! -L "$audit_evidence_dir" ]] || { - echo "ERROR: protected managed-image cache has no reviewed mcporter audit evidence" >&2 - exit 1 - } - [[ -z "$(find "$audit_evidence_dir" -type l -print -quit)" ]] || { - echo "ERROR: protected managed-image reviewed audit evidence contains a symlink" >&2 - exit 1 - } - audit_receipt="$audit_evidence_dir/mcporter-runtime.receipt.json" - audit_raw_report="$audit_evidence_dir/mcporter-runtime.raw.json" - audit_receipt_sha_file="$audit_evidence_dir/mcporter-runtime.receipt.sha256" - [[ -f "$audit_receipt" && ! -L "$audit_receipt" && -s "$audit_receipt" ]] || { - echo "ERROR: protected managed-image reviewed audit receipt is missing or unsafe" >&2 - exit 1 - } - [[ -f "$audit_raw_report" && ! -L "$audit_raw_report" && -s "$audit_raw_report" ]] || { - echo "ERROR: protected managed-image reviewed audit raw report is missing or unsafe" >&2 - exit 1 - } - audit_receipt_sha256="$(sha256sum "$audit_receipt" | awk '{print $1}')" - [[ "$audit_receipt_sha256" =~ ^[a-f0-9]{64}$ ]] || { - echo "ERROR: protected managed-image reviewed audit receipt hash is invalid" >&2 - exit 1 - } - if [[ -n "$cache_to" ]]; then - printf '%s\n' "$audit_receipt_sha256" >"$audit_receipt_sha_file" - chmod 0400 "$audit_receipt_sha_file" - else - [[ -f "$audit_receipt_sha_file" && ! -L "$audit_receipt_sha_file" ]] || { - echo "ERROR: protected managed-image reviewed audit receipt hash is missing or unsafe" >&2 - exit 1 - } - read -r recorded_audit_receipt_sha256 <"$audit_receipt_sha_file" - [[ "$recorded_audit_receipt_sha256" =~ ^[a-f0-9]{64}$ && "$recorded_audit_receipt_sha256" == "$audit_receipt_sha256" ]] || { - echo "ERROR: protected managed-image reviewed audit receipt hash does not match" >&2 - exit 1 - } - fi + validate_audit_evidence "$audit_evidence_dir" fi work_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/nemoclaw-protected-images.XXXXXX")" @@ -252,17 +244,6 @@ trap restore_worktree EXIT trap 'exit 130' INT trap 'exit 143' TERM -if [[ -n "$cache_to" ]]; then - cp -pR -- "$source_seed_dir" "$seed_backup" - seed_overlay_active=1 - install -d -m 0700 "$source_seed_dir/reviewed-npm-audit" - install -m 0400 \ - "$audit_receipt" \ - "$audit_raw_report" \ - "$audit_receipt_sha_file" \ - "$source_seed_dir/reviewed-npm-audit/" -fi - if [[ -n "$cache_from" ]]; then imported_seed="$work_dir/npm-cache-seed-import" node --experimental-strip-types --no-warnings "$seed_helper" copy \ @@ -566,9 +547,6 @@ if [[ -n "$cache_to" ]]; then --os "$npm_target_os" \ --cpu "$npm_target_cpu" \ --libc "$npm_target_libc" - cp -pR -- \ - "$source_seed_dir/reviewed-npm-audit" \ - "$cache_to/npm-cache-seed/reviewed-npm-audit" node --experimental-strip-types --no-warnings "$seed_helper" export \ --lockfile "$source_mcp_lockfile" \ --output "$cache_to/mcp-runtime-npm-cache-seed" \ diff --git a/scripts/checks/materialize-locked-npm-cache-seed.mts b/scripts/checks/materialize-locked-npm-cache-seed.mts index 7891c90f322..c446f44be70 100644 --- a/scripts/checks/materialize-locked-npm-cache-seed.mts +++ b/scripts/checks/materialize-locked-npm-cache-seed.mts @@ -7,7 +7,6 @@ import { chmod, copyFile, lstat, - mkdir, mkdtemp, open, readdir, @@ -23,15 +22,6 @@ const MANIFEST_KIND = "nemoclaw-locked-npm-cache-seed-v1"; const MANIFEST_NAME = "manifest.json"; const REGISTRY_ORIGIN = "https://registry.npmjs.org"; const MAX_ARCHIVE_BYTES = 32 * 1024 * 1024; -const MAX_AUDIT_RAW_REPORT_BYTES = 64 * 1024 * 1024; -const MAX_AUDIT_RECEIPT_BYTES = 64 * 1024; -const MAX_AUDIT_RECEIPT_SHA256_BYTES = 65; -const REVIEWED_AUDIT_DIRECTORY = "reviewed-npm-audit"; -const REVIEWED_AUDIT_FILES = [ - "mcporter-runtime.raw.json", - "mcporter-runtime.receipt.json", - "mcporter-runtime.receipt.sha256", -] as const; const DOWNLOAD_CONCURRENCY = 6; const DOWNLOAD_ATTEMPTS = 4; const DOWNLOAD_TIMEOUT_MS = 30_000; @@ -103,71 +93,6 @@ function lockSha256(source: Uint8Array): string { return crypto.createHash("sha256").update(source).digest("hex"); } -type ReviewedAuditEvidence = Readonly<{ - rawReport: Buffer; - receipt: Buffer; - receiptSha256: Buffer; -}>; - -async function reviewedAuditEvidence(seed: string): Promise { - const directory = path.join(seed, REVIEWED_AUDIT_DIRECTORY); - let status; - try { - status = await lstat(directory); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; - throw error; - } - if (!status.isDirectory() || status.isSymbolicLink()) { - throw new Error("reviewed npm audit evidence must be one non-symlink directory"); - } - const entries = await readdir(directory, { withFileTypes: true }); - if ( - entries.some((entry) => !entry.isFile() || entry.isSymbolicLink()) || - JSON.stringify(entries.map(({ name }) => name).sort()) !== - JSON.stringify([...REVIEWED_AUDIT_FILES].sort()) - ) { - throw new Error("reviewed npm audit evidence contains missing or unexpected files"); - } - const rawReport = await exactFileSource( - path.join(directory, REVIEWED_AUDIT_FILES[0]), - "reviewed npm audit raw report", - MAX_AUDIT_RAW_REPORT_BYTES, - ); - const receipt = await exactFileSource( - path.join(directory, REVIEWED_AUDIT_FILES[1]), - "reviewed npm audit receipt", - MAX_AUDIT_RECEIPT_BYTES, - ); - const receiptSha256 = await exactFileSource( - path.join(directory, REVIEWED_AUDIT_FILES[2]), - "reviewed npm audit receipt hash", - MAX_AUDIT_RECEIPT_SHA256_BYTES, - ); - if (rawReport.byteLength < 1 || receipt.byteLength < 1) { - throw new Error("reviewed npm audit evidence size is invalid"); - } - const recordedReceiptSha256 = receiptSha256.toString("utf8"); - const actualReceiptSha256 = `${lockSha256(receipt)}\n`; - if (recordedReceiptSha256 !== actualReceiptSha256) { - throw new Error("reviewed npm audit receipt hash does not match"); - } - let parsedReceipt: JsonRecord; - try { - parsedReceipt = record(JSON.parse(receipt.toString("utf8")), "reviewed npm audit receipt"); - } catch { - throw new Error("reviewed npm audit receipt is not valid JSON"); - } - if ( - typeof parsedReceipt.rawResponseSha256 !== "string" || - !/^[a-f0-9]{64}$/u.test(parsedReceipt.rawResponseSha256) || - parsedReceipt.rawResponseSha256 !== lockSha256(rawReport) - ) { - throw new Error("reviewed npm audit raw report hash does not match its receipt"); - } - return { rawReport, receipt, receiptSha256 }; -} - function archiveIntegrity(source: Uint8Array): string { return `sha512-${crypto.createHash("sha512").update(source).digest("base64")}`; } @@ -326,7 +251,7 @@ export function lockedArchives( return [...byArchive.values()].sort((left, right) => left.archive.localeCompare(right.archive)); } -async function exactFileSource(file: string, label: string, maxBytes?: number): Promise { +async function exactFileSource(file: string, label: string): Promise { if (!path.isAbsolute(file) || file.includes("\n")) { throw new Error(`${label} must be an absolute path`); } @@ -335,7 +260,7 @@ async function exactFileSource(file: string, label: string, maxBytes?: number): }); try { const status = await handle.stat(); - if (!status.isFile() || (maxBytes !== undefined && status.size > maxBytes)) { + if (!status.isFile()) { throw new Error(`${label} must be one regular non-symlink file`); } return await handle.readFile(); @@ -536,7 +461,6 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { const target = exactTarget(options.target); const expected = lockedArchives(lockSource.toString("utf8"), target); const seed = await exactDirectory(options.seed, "seed directory"); - const auditEvidence = await reviewedAuditEvidence(seed); const manifestSource = await exactFileSource(path.join(seed, MANIFEST_NAME), "seed manifest"); const manifest = parseManifest(manifestSource.toString("utf8")); if (manifest.lockSha256 !== lockSha256(lockSource)) { @@ -553,11 +477,7 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { throw new Error("npm cache seed manifest does not contain the complete locked archive set"); } const entries = await readdir(seed, { withFileTypes: true }); - const expectedNames = [ - ...expected.map(({ archive }) => archive), - MANIFEST_NAME, - ...(auditEvidence ? [REVIEWED_AUDIT_DIRECTORY] : []), - ].sort(); + const expectedNames = [...expected.map(({ archive }) => archive), MANIFEST_NAME].sort(); const actualNames = entries.map(({ name }) => name).sort(); if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) { throw new Error("npm cache seed directory contains missing or unexpected files"); @@ -582,20 +502,6 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { await copyFile(path.join(seed, archive.archive), destination); await chmod(destination, 0o444); } - if (auditEvidence) { - const auditDirectory = path.join(directory.temporary, REVIEWED_AUDIT_DIRECTORY); - await mkdir(auditDirectory, { mode: 0o700 }); - for (const [name, contents] of [ - [REVIEWED_AUDIT_FILES[0], auditEvidence.rawReport], - [REVIEWED_AUDIT_FILES[1], auditEvidence.receipt], - [REVIEWED_AUDIT_FILES[2], auditEvidence.receiptSha256], - ] as const) { - await writeFile(path.join(auditDirectory, name), contents, { - flag: "wx", - mode: 0o400, - }); - } - } await directory.commit(); } catch (error) { await rm(directory.temporary, { force: true, recursive: true }); diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index 0c69ac41264..9ace1c7ed14 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -371,87 +371,61 @@ function responseEvidence( ].join(" "); } +function rejectedAuditResponse( + result: Readonly<{ status: number | null; stdout: string }>, + reason: NpmAuditFailureReason, + retryable: boolean, + fields: readonly string[] = [], +): NpmAuditResponseClassification { + return { + failure: { + diagnostic: responseEvidence(result, [`condition=${reason}`, ...fields]), + reason, + retryable, + }, + }; +} + /** Classify one npm response without retaining payload text or unbounded field names. */ export function classifyNpmAuditResponse(result: { status: number | null; stderr: string; stdout: string; }): NpmAuditResponseClassification { - if (!result.stdout.trim()) { - return { - failure: { - diagnostic: responseEvidence(result, ["condition=empty-output"]), - reason: "empty-output", - retryable: true, - }, - }; - } + if (!result.stdout.trim()) return rejectedAuditResponse(result, "empty-output", true); let value: unknown; try { value = JSON.parse(result.stdout); } catch { - return { - failure: { - diagnostic: responseEvidence(result, ["condition=invalid-json"]), - reason: "invalid-json", - retryable: true, - }, - }; + return rejectedAuditResponse(result, "invalid-json", true); } if (typeof value !== "object" || value === null || Array.isArray(value)) { - return { - failure: { - diagnostic: responseEvidence(result, [ - "condition=incomplete-report", - `required-field=report:${valueShape(value)}`, - ]), - reason: "incomplete-report", - retryable: false, - }, - }; + return rejectedAuditResponse(result, "incomplete-report", false, [ + `required-field=report:${valueShape(value)}`, + ]); } const report = value as Record; const invalidField = firstInvalidAuditField(report); if (report.error !== undefined) { const code = transportCode(report, result.stderr); const reason = code ? "registry-network-error" : "npm-error-document"; - return { - failure: { - diagnostic: responseEvidence(result, [ - `condition=${reason}`, - ...(code ? [`transport=${code}`] : []), - ...(invalidField ? [`required-field=${invalidField}`] : []), - ]), - reason, - retryable: code !== undefined, - }, - }; + return rejectedAuditResponse(result, reason, code !== undefined, [ + ...(code ? [`transport=${code}`] : []), + ...(invalidField ? [`required-field=${invalidField}`] : []), + ]); } if (invalidField) { - return { - failure: { - diagnostic: responseEvidence(result, [ - "condition=incomplete-report", - `required-field=${invalidField}`, - ]), - reason: "incomplete-report", - retryable: false, - }, - }; + return rejectedAuditResponse(result, "incomplete-report", false, [ + `required-field=${invalidField}`, + ]); } const counts = vulnerabilityCounts(report); const findingCount = SEVERITIES.reduce((total, severity) => total + counts[severity], 0); if (result.status === null || result.status > 1 || (result.status !== 0 && findingCount === 0)) { - return { - failure: { - diagnostic: responseEvidence(result, ["condition=invalid-exit-status"]), - reason: "invalid-exit-status", - retryable: false, - }, - }; + return rejectedAuditResponse(result, "invalid-exit-status", false); } return { report }; } @@ -499,15 +473,9 @@ export function runNpmAuditWithRetry( } lastResult = result; const classified: NpmAuditResponseClassification = result.error - ? { - failure: { - diagnostic: responseEvidence(result, [ - `condition=timeout timeout-ms=${NPM_AUDIT_ATTEMPT_TIMEOUT_MS}`, - ]), - reason: "timeout", - retryable: true, - }, - } + ? rejectedAuditResponse(result, "timeout", true, [ + `timeout-ms=${NPM_AUDIT_ATTEMPT_TIMEOUT_MS}`, + ]) : classifyNpmAuditResponse(result); if ("report" in classified) return { report: classified.report, result }; lastFailure = classified.failure; diff --git a/scripts/lib/verify-mcporter-audit.sh b/scripts/lib/verify-mcporter-audit.sh index 390c58497e5..798daf83e7d 100755 --- a/scripts/lib/verify-mcporter-audit.sh +++ b/scripts/lib/verify-mcporter-audit.sh @@ -5,39 +5,20 @@ set -euo pipefail secret_root="${NEMOCLAW_MCPORTER_AUDIT_SECRET_ROOT:-/run/secrets}" -seed_root="${NEMOCLAW_MCPORTER_AUDIT_SEED_ROOT:-/run/nemoclaw-mcporter-audit-cache}" secret_receipt="$secret_root/nemoclaw-mcporter-audit-receipt" secret_raw_report="$secret_root/nemoclaw-mcporter-audit-raw-report" -seed_audit="$seed_root/reviewed-npm-audit" receipt="" raw_report="" -receipt_sha256="" +receipt_sha256="${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" -if [[ -e "$secret_receipt" || -L "$secret_receipt" || -e "$secret_raw_report" || -L "$secret_raw_report" || -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]]; then +if [[ -e "$secret_receipt" || -L "$secret_receipt" || -e "$secret_raw_report" || -L "$secret_raw_report" || -n "$receipt_sha256" ]]; then if [[ ! -f "$secret_receipt" || -L "$secret_receipt" || ! -f "$secret_raw_report" || -L "$secret_raw_report" ]] \ - || ! printf '%s' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}'; then + || ! printf '%s' "$receipt_sha256" | grep -qxE '[0-9a-f]{64}'; then echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2 exit 1 fi receipt="$secret_receipt" raw_report="$secret_raw_report" - receipt_sha256="$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" -elif [[ -e "$seed_audit" || -L "$seed_audit" ]]; then - [[ -d "$seed_audit" && ! -L "$seed_audit" && - -f "$seed_audit/mcporter-runtime.receipt.json" && ! -L "$seed_audit/mcporter-runtime.receipt.json" && - -f "$seed_audit/mcporter-runtime.raw.json" && ! -L "$seed_audit/mcporter-runtime.raw.json" && - -f "$seed_audit/mcporter-runtime.receipt.sha256" && ! -L "$seed_audit/mcporter-runtime.receipt.sha256" ]] \ - || { - echo "ERROR: seed-cached mcporter audit evidence is incomplete" >&2 - exit 1 - } - receipt="$seed_audit/mcporter-runtime.receipt.json" - raw_report="$seed_audit/mcporter-runtime.raw.json" - read -r receipt_sha256 <"$seed_audit/mcporter-runtime.receipt.sha256" - printf '%s' "$receipt_sha256" | grep -qxE '[0-9a-f]{64}' || { - echo "ERROR: seed-cached mcporter audit receipt SHA-256 is invalid" >&2 - exit 1 - } fi if [[ -z "$receipt" ]]; then diff --git a/test/install/materialize-locked-npm-cache-seed.test.ts b/test/install/materialize-locked-npm-cache-seed.test.ts index 08072b4e98c..ee2bdd41f6d 100644 --- a/test/install/materialize-locked-npm-cache-seed.test.ts +++ b/test/install/materialize-locked-npm-cache-seed.test.ts @@ -6,13 +6,11 @@ import { appendFileSync, chmodSync, existsSync, - mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, - statSync, unlinkSync, writeFileSync, } from "node:fs"; @@ -73,28 +71,6 @@ function writeLock(root: string, archives: readonly LockedArchive[]): string { return lockfile; } -function writeReviewedAuditEvidence(seed: string): { - directory: string; - rawReport: Buffer; - receipt: Buffer; -} { - const directory = path.join(seed, "reviewed-npm-audit"); - const rawReport = Buffer.from( - '{"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n', - ); - const receipt = Buffer.from( - `${JSON.stringify({ rawResponseSha256: crypto.createHash("sha256").update(rawReport).digest("hex") })}\n`, - ); - mkdirSync(directory); - writeFileSync(path.join(directory, "mcporter-runtime.raw.json"), rawReport); - writeFileSync(path.join(directory, "mcporter-runtime.receipt.json"), receipt); - writeFileSync( - path.join(directory, "mcporter-runtime.receipt.sha256"), - `${crypto.createHash("sha256").update(receipt).digest("hex")}\n`, - ); - return { directory, rawReport, receipt }; -} - let testRoot = ""; beforeEach(() => { @@ -286,77 +262,6 @@ describe("locked npm cache seed materialization", () => { ).rejects.toThrow("npm cache seed directory contains missing or unexpected files"); }); - it("copies reviewed npm audit evidence only after receipt and raw-report integrity checks", async () => { - const alpha = archive("alpha", "alpha archive"); - const lockfile = writeLock(testRoot, [alpha.locked]); - const seed = path.join(testRoot, "seed"); - const copied = path.join(testRoot, "copied"); - await materializeLockedNpmCacheSeed({ - downloadArchive: async () => alpha.bytes, - lockfile, - output: seed, - target: TARGET, - }); - const evidence = writeReviewedAuditEvidence(seed); - - await verifyAndCopyLockedNpmCacheSeed({ lockfile, output: copied, seed, target: TARGET }); - - const copiedEvidence = path.join(copied, "reviewed-npm-audit"); - expect(readFileSync(path.join(copiedEvidence, "mcporter-runtime.raw.json"))).toEqual( - evidence.rawReport, - ); - expect(readFileSync(path.join(copiedEvidence, "mcporter-runtime.receipt.json"))).toEqual( - evidence.receipt, - ); - expect(statSync(copiedEvidence).mode & 0o777).toBe(0o700); - expect(statSync(path.join(copiedEvidence, "mcporter-runtime.raw.json")).mode & 0o777).toBe( - 0o400, - ); - }); - - it.each([ - { - expected: "reviewed npm audit raw report hash does not match its receipt", - mutate: (directory: string) => - appendFileSync(path.join(directory, "mcporter-runtime.raw.json"), "tampered"), - name: "raw report", - }, - { - expected: "reviewed npm audit receipt hash does not match", - mutate: (directory: string) => - appendFileSync(path.join(directory, "mcporter-runtime.receipt.json"), "tampered"), - name: "receipt", - }, - { - expected: "reviewed npm audit receipt hash does not match", - mutate: (directory: string) => - writeFileSync(path.join(directory, "mcporter-runtime.receipt.sha256"), `${"0".repeat(64)}\n`), - name: "receipt hash", - }, - { - expected: "reviewed npm audit receipt hash must be one regular non-symlink file", - mutate: (directory: string) => - writeFileSync(path.join(directory, "mcporter-runtime.receipt.sha256"), "x".repeat(66)), - name: "oversized receipt hash", - }, - ])("rejects tampered reviewed npm audit $name evidence", async ({ expected, mutate }) => { - const alpha = archive("alpha", "alpha archive"); - const lockfile = writeLock(testRoot, [alpha.locked]); - const seed = path.join(testRoot, "seed"); - await materializeLockedNpmCacheSeed({ - downloadArchive: async () => alpha.bytes, - lockfile, - output: seed, - target: TARGET, - }); - const { directory } = writeReviewedAuditEvidence(seed); - mutate(directory); - - await expect( - verifyAndCopyLockedNpmCacheSeed({ lockfile, seed, target: TARGET }), - ).rejects.toThrow(expected); - }); - it.skipIf(process.platform === "win32")( "rejects a lock-pinned archive replaced with a symlink", async () => { diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index 5bf2f266873..4571d181ede 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -462,26 +462,12 @@ describe("protected managed-image build-cache boundary", () => { expect(existsSync(path.join(cacheRoot, "messaging-npm-cache-seed", "manifest.json"))).toBe( true, ); - expect(existsSync(path.join(cacheRoot, "reviewed-npm-audit", "mcporter-runtime.raw.json"))).toBe( - true, - ); expect( readFileSync( path.join(cacheRoot, "reviewed-npm-audit", "mcporter-runtime.receipt.sha256"), "utf8", ), ).toBe(`${DIGEST}\n`); - expect( - readFileSync( - path.join( - cacheRoot, - "npm-cache-seed", - "reviewed-npm-audit", - "mcporter-runtime.receipt.sha256", - ), - "utf8", - ), - ).toBe(`${DIGEST}\n`); expect(recordedBuildInvocation("openclaw")).toContain( `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(cacheRoot)}/reviewed-npm-audit/mcporter-runtime.receipt.json`, ); @@ -574,31 +560,31 @@ describe("protected managed-image build-cache boundary", () => { expect(existsSync(dockerLog)).toBe(false); }); - it("rejects an imported cache without reviewed mcporter audit evidence (#11088)", () => { - const cacheRoot = path.join(testRoot, "imported-cache"); - completeImportedCache(cacheRoot); - rmSync(path.join(cacheRoot, "reviewed-npm-audit"), { recursive: true }); - - const result = runBuild(REPO_ROOT, ["--cache-from", cacheRoot]); - - expect(result.status, result.stderr).toBe(1); - expect(result.stderr).toContain("cache has no reviewed mcporter audit evidence"); - expect(existsSync(dockerLog)).toBe(false); - }); - - it("rejects a changed reviewed audit receipt before invoking Docker (#11088)", () => { + it.each([ + [ + "missing", + (cacheRoot: string) => rmSync(path.join(cacheRoot, "reviewed-npm-audit"), { recursive: true }), + "reviewed audit evidence is missing or unsafe", + ], + [ + "changed", + (cacheRoot: string) => + writeFileSync( + path.join(cacheRoot, "reviewed-npm-audit", "mcporter-runtime.receipt.sha256"), + `${"c".repeat(64)}\n`, + ), + "reviewed audit receipt hash does not match", + ], + ])("rejects %s reviewed audit evidence before invoking Docker (#11088)", (_case, mutate, error) => { const cacheRoot = path.join(testRoot, "imported-cache"); completeImportedCache(cacheRoot); stubBuildInvocation(); - writeFileSync( - path.join(cacheRoot, "reviewed-npm-audit", "mcporter-runtime.receipt.sha256"), - `${"c".repeat(64)}\n`, - ); + mutate(cacheRoot); const result = runBuild(REPO_ROOT, ["--cache-from", cacheRoot]); expect(result.status, result.stderr).toBe(1); - expect(result.stderr).toContain("reviewed audit receipt hash does not match"); + expect(result.stderr).toContain(error); expect(existsSync(dockerLog)).toBe(false); }); diff --git a/test/security/mcporter-audit-evidence.test.ts b/test/security/mcporter-audit-evidence.test.ts deleted file mode 100644 index c9f58e97768..00000000000 --- a/test/security/mcporter-audit-evidence.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { spawnSync } from "node:child_process"; -import crypto from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -const REPO_ROOT = path.join(import.meta.dirname, "../.."); -const SCRIPT = path.join(REPO_ROOT, "scripts", "lib", "verify-mcporter-audit.sh"); - -let root = ""; -let seedRoot = ""; -let secretRoot = ""; -let nodeLog = ""; - -function runGate(receiptSha256 = "") { - return spawnSync("bash", [SCRIPT], { - encoding: "utf8", - env: { - ...process.env, - PATH: `${path.join(root, "bin")}${path.delimiter}${process.env.PATH}`, - NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: receiptSha256, - NEMOCLAW_MCPORTER_AUDIT_SECRET_ROOT: secretRoot, - NEMOCLAW_MCPORTER_AUDIT_SEED_ROOT: seedRoot, - NEMOCLAW_TEST_NODE_LOG: nodeLog, - }, - }); -} - -function writeEvidence(directory: string, receiptName: string, rawName: string): string { - fs.mkdirSync(directory, { recursive: true }); - const receipt = Buffer.from('{"receipt":"fixture"}\n'); - fs.writeFileSync(path.join(directory, receiptName), receipt); - fs.writeFileSync(path.join(directory, rawName), '{"vulnerabilities":{}}\n'); - return crypto.createHash("sha256").update(receipt).digest("hex"); -} - -beforeEach(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcporter-audit-evidence-")); - seedRoot = path.join(root, "seed"); - secretRoot = path.join(root, "secrets"); - nodeLog = path.join(root, "node.log"); - fs.mkdirSync(path.join(root, "bin")); - fs.mkdirSync(seedRoot); - fs.mkdirSync(secretRoot); - fs.writeFileSync( - path.join(root, "bin", "node"), - '#!/usr/bin/env bash\nprintf \'%s\\n\' "$*" >"$NEMOCLAW_TEST_NODE_LOG"\n', - { mode: 0o755 }, - ); -}); - -afterEach(() => { - fs.rmSync(root, { force: true, recursive: true }); -}); - -describe("mcporter reviewed audit evidence gate", () => { - it("runs the live fail-closed audit when no receipt source exists", () => { - const result = runGate(); - - expect(result.status, result.stderr).toBe(0); - expect(fs.readFileSync(nodeLog, "utf8")).toContain( - "/scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime", - ); - }); - - it("verifies complete seed-carried evidence before invoking the receipt gate", () => { - const directory = path.join(seedRoot, "reviewed-npm-audit"); - const receiptSha256 = writeEvidence( - directory, - "mcporter-runtime.receipt.json", - "mcporter-runtime.raw.json", - ); - fs.writeFileSync(path.join(directory, "mcporter-runtime.receipt.sha256"), `${receiptSha256}\n`); - - const result = runGate(); - - expect(result.status, result.stderr).toBe(0); - const invocation = fs.readFileSync(nodeLog, "utf8"); - expect(invocation).toContain("/scripts/lib/npm-audit-receipt.mts --receipt"); - expect(invocation).toContain(path.join(directory, "mcporter-runtime.receipt.json")); - expect(invocation).toContain(path.join(directory, "mcporter-runtime.raw.json")); - }); - - it("rejects incomplete or hash-mismatched seed evidence without a live fallback", () => { - const directory = path.join(seedRoot, "reviewed-npm-audit"); - const receiptSha256 = writeEvidence( - directory, - "mcporter-runtime.receipt.json", - "mcporter-runtime.raw.json", - ); - - const incomplete = runGate(); - expect(incomplete.status).not.toBe(0); - expect(incomplete.stderr).toContain("seed-cached mcporter audit evidence is incomplete"); - expect(fs.existsSync(nodeLog)).toBe(false); - - fs.writeFileSync(path.join(directory, "mcporter-runtime.receipt.sha256"), `${receiptSha256}\n`); - fs.appendFileSync(path.join(directory, "mcporter-runtime.receipt.json"), "tampered"); - const tampered = runGate(); - expect(tampered.status).not.toBe(0); - expect(tampered.stderr).toContain("cached mcporter audit receipt hash does not match"); - expect(fs.existsSync(nodeLog)).toBe(false); - }); - - it("requires paired secret evidence and its exact receipt hash", () => { - const receiptSha256 = writeEvidence( - secretRoot, - "nemoclaw-mcporter-audit-receipt", - "nemoclaw-mcporter-audit-raw-report", - ); - - const result = runGate(receiptSha256); - - expect(result.status, result.stderr).toBe(0); - expect(fs.readFileSync(nodeLog, "utf8")).toContain( - "/scripts/lib/npm-audit-receipt.mts --receipt", - ); - }); -}); diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index 95e7c7b12b4..5eb9d9798ba 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -255,14 +255,12 @@ describe("mcporter image supply-chain controls", () => { "NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH=mcporter-runtime NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR=artifacts/reviewed-npm-audit", ); expect(producer).toContain("FROM scratch AS protected-mcporter-audit-evidence"); - expect(contents).toContain("FROM scratch AS protected-mcporter-audit-cache"); expect(contents).toContain( - "--mount=type=bind,from=protected-mcporter-audit-cache,source=/seed,target=/run/nemoclaw-mcporter-audit-cache", + "--mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false", ); expect(contents).toContain("bash /scripts/lib/verify-mcporter-audit.sh"); - expect(mcporterAuditHelper).toContain("seed-cached mcporter audit evidence is incomplete"); expect(mcporterAuditHelper).toContain( - "seed-cached mcporter audit receipt SHA-256 is invalid", + "cached mcporter audit requires paired receipt, raw report, and receipt SHA-256", ); expect(mcporterAuditHelper.replace(/\\\s*\n/g, " ").replace(/\s+/g, " ")).toContain( `printf '%s %s\\n' "$receipt_sha256" "$receipt" | sha256sum --check --status -`, From 3d4672ad8a18ccadcff0b9193cf1478283545459 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 7 Sep 2026 16:01:36 +0700 Subject: [PATCH 03/56] fix: order audit response type guards --- scripts/lib/reviewed-npm-audit.mts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index 9ace1c7ed14..d28f262f167 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -316,21 +316,21 @@ function valueShape(value: unknown): string { } function firstInvalidAuditField(value: unknown): string | undefined { - if (typeof value !== "object" || value === null || Array.isArray(value)) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { return `report:${valueShape(value)}`; } const report = value as Record; if ( - typeof report.metadata !== "object" || report.metadata === null || + typeof report.metadata !== "object" || Array.isArray(report.metadata) ) { return `metadata:${valueShape(report.metadata)}`; } const metadata = report.metadata as Record; if ( - typeof metadata.vulnerabilities !== "object" || metadata.vulnerabilities === null || + typeof metadata.vulnerabilities !== "object" || Array.isArray(metadata.vulnerabilities) ) { return `metadata.vulnerabilities:${valueShape(metadata.vulnerabilities)}`; @@ -401,7 +401,7 @@ export function classifyNpmAuditResponse(result: { return rejectedAuditResponse(result, "invalid-json", true); } - if (typeof value !== "object" || value === null || Array.isArray(value)) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { return rejectedAuditResponse(result, "incomplete-report", false, [ `required-field=report:${valueShape(value)}`, ]); From db2b59820a4f820c2fe616d432333a9ac6618180 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 7 Sep 2026 17:26:06 +0700 Subject: [PATCH 04/56] fix: carry audit through protected cache seed --- Dockerfile | 6 ++ .../checks/build-protected-managed-images.sh | 4 + .../materialize-locked-npm-cache-seed.mts | 70 +++++++++++++++- scripts/lib/verify-mcporter-audit.sh | 14 ++++ .../materialize-locked-npm-cache-seed.test.ts | 80 +++++++++++++++++++ ...otected-managed-image-build-script.test.ts | 62 ++++++++------ test/security/mcporter-supply-chain.test.ts | 8 +- 7 files changed, 214 insertions(+), 30 deletions(-) diff --git a/Dockerfile b/Dockerfile index abcef761735..f28044637cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -600,6 +600,11 @@ COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/ COPY --from=mcp-tool-discovery-runtime /opt/mcp-tool-discovery-runtime/dist/ /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime/ +# Protected consumers import reviewed audit evidence through the existing +# locked seed because their build driver comes from trusted main. +FROM scratch AS protected-mcporter-audit-cache +COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/ /seed/ + # Stage 3: Runtime image — pull cached base from GHCR # hadolint ignore=DL3006 FROM ${BASE_IMAGE} @@ -824,6 +829,7 @@ RUN command -v codex-acp >/dev/null RUN --network=default \ --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ + --mount=type=bind,from=protected-mcporter-audit-cache,source=/seed,target=/run/nemoclaw-mcporter-audit-cache \ set -eu; \ if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index be132b2ebe1..f42df3545a1 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -547,6 +547,10 @@ if [[ -n "$cache_to" ]]; then --os "$npm_target_os" \ --cpu "$npm_target_cpu" \ --libc "$npm_target_libc" + install -d -m 0700 "$cache_to/npm-cache-seed/reviewed-npm-audit" + install -m 0400 \ + "$audit_receipt" "$audit_raw_report" "$audit_evidence_dir/mcporter-runtime.receipt.sha256" \ + "$cache_to/npm-cache-seed/reviewed-npm-audit/" node --experimental-strip-types --no-warnings "$seed_helper" export \ --lockfile "$source_mcp_lockfile" \ --output "$cache_to/mcp-runtime-npm-cache-seed" \ diff --git a/scripts/checks/materialize-locked-npm-cache-seed.mts b/scripts/checks/materialize-locked-npm-cache-seed.mts index c446f44be70..29da656ebd1 100644 --- a/scripts/checks/materialize-locked-npm-cache-seed.mts +++ b/scripts/checks/materialize-locked-npm-cache-seed.mts @@ -7,6 +7,7 @@ import { chmod, copyFile, lstat, + mkdir, mkdtemp, open, readdir, @@ -22,6 +23,12 @@ const MANIFEST_KIND = "nemoclaw-locked-npm-cache-seed-v1"; const MANIFEST_NAME = "manifest.json"; const REGISTRY_ORIGIN = "https://registry.npmjs.org"; const MAX_ARCHIVE_BYTES = 32 * 1024 * 1024; +const REVIEWED_AUDIT_DIRECTORY = "reviewed-npm-audit"; +const REVIEWED_AUDIT_FILES = [ + { maxBytes: 64 * 1024 * 1024, name: "mcporter-runtime.raw.json" }, + { maxBytes: 64 * 1024, name: "mcporter-runtime.receipt.json" }, + { maxBytes: 65, name: "mcporter-runtime.receipt.sha256" }, +] as const; const DOWNLOAD_CONCURRENCY = 6; const DOWNLOAD_ATTEMPTS = 4; const DOWNLOAD_TIMEOUT_MS = 30_000; @@ -93,6 +100,46 @@ function lockSha256(source: Uint8Array): string { return crypto.createHash("sha256").update(source).digest("hex"); } +async function reviewedAuditEvidence(seed: string): Promise { + const directory = path.join(seed, REVIEWED_AUDIT_DIRECTORY); + let status; + try { + status = await lstat(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + if (!status.isDirectory() || status.isSymbolicLink()) { + throw new Error("reviewed npm audit evidence must be one non-symlink directory"); + } + const entries = await readdir(directory, { withFileTypes: true }); + if ( + entries.some((entry) => !entry.isFile() || entry.isSymbolicLink()) || + JSON.stringify(entries.map(({ name }) => name).sort()) !== + JSON.stringify(REVIEWED_AUDIT_FILES.map(({ name }) => name).sort()) + ) { + throw new Error("reviewed npm audit evidence contains missing or unexpected files"); + } + const evidence = await Promise.all( + REVIEWED_AUDIT_FILES.map(({ maxBytes, name }) => + exactFileSource(path.join(directory, name), `reviewed npm audit ${name}`, maxBytes), + ), + ); + const [rawReport, receipt, receiptHash] = evidence; + if ( + !rawReport?.length || + !receipt?.length || + receiptHash?.toString("utf8") !== `${lockSha256(receipt)}\n` + ) { + throw new Error("reviewed npm audit evidence failed receipt integrity validation"); + } + const parsedReceipt = record(JSON.parse(receipt.toString("utf8")), "reviewed npm audit receipt"); + if (parsedReceipt.rawResponseSha256 !== lockSha256(rawReport)) { + throw new Error("reviewed npm audit evidence failed raw-report integrity validation"); + } + return evidence; +} + function archiveIntegrity(source: Uint8Array): string { return `sha512-${crypto.createHash("sha512").update(source).digest("base64")}`; } @@ -251,7 +298,7 @@ export function lockedArchives( return [...byArchive.values()].sort((left, right) => left.archive.localeCompare(right.archive)); } -async function exactFileSource(file: string, label: string): Promise { +async function exactFileSource(file: string, label: string, maxBytes?: number): Promise { if (!path.isAbsolute(file) || file.includes("\n")) { throw new Error(`${label} must be an absolute path`); } @@ -260,7 +307,7 @@ async function exactFileSource(file: string, label: string): Promise { }); try { const status = await handle.stat(); - if (!status.isFile()) { + if (!status.isFile() || (maxBytes !== undefined && status.size > maxBytes)) { throw new Error(`${label} must be one regular non-symlink file`); } return await handle.readFile(); @@ -461,6 +508,7 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { const target = exactTarget(options.target); const expected = lockedArchives(lockSource.toString("utf8"), target); const seed = await exactDirectory(options.seed, "seed directory"); + const auditEvidence = await reviewedAuditEvidence(seed); const manifestSource = await exactFileSource(path.join(seed, MANIFEST_NAME), "seed manifest"); const manifest = parseManifest(manifestSource.toString("utf8")); if (manifest.lockSha256 !== lockSha256(lockSource)) { @@ -477,7 +525,11 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { throw new Error("npm cache seed manifest does not contain the complete locked archive set"); } const entries = await readdir(seed, { withFileTypes: true }); - const expectedNames = [...expected.map(({ archive }) => archive), MANIFEST_NAME].sort(); + const expectedNames = [ + ...expected.map(({ archive }) => archive), + MANIFEST_NAME, + ...(auditEvidence ? [REVIEWED_AUDIT_DIRECTORY] : []), + ].sort(); const actualNames = entries.map(({ name }) => name).sort(); if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) { throw new Error("npm cache seed directory contains missing or unexpected files"); @@ -502,6 +554,18 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { await copyFile(path.join(seed, archive.archive), destination); await chmod(destination, 0o444); } + if (auditEvidence) { + const auditDirectory = path.join(directory.temporary, REVIEWED_AUDIT_DIRECTORY); + await mkdir(auditDirectory, { mode: 0o700 }); + await Promise.all( + REVIEWED_AUDIT_FILES.map(({ name }, index) => + writeFile(path.join(auditDirectory, name), auditEvidence[index], { + flag: "wx", + mode: 0o400, + }), + ), + ); + } await directory.commit(); } catch (error) { await rm(directory.temporary, { force: true, recursive: true }); diff --git a/scripts/lib/verify-mcporter-audit.sh b/scripts/lib/verify-mcporter-audit.sh index 798daf83e7d..2726b8409e2 100755 --- a/scripts/lib/verify-mcporter-audit.sh +++ b/scripts/lib/verify-mcporter-audit.sh @@ -5,6 +5,7 @@ set -euo pipefail secret_root="${NEMOCLAW_MCPORTER_AUDIT_SECRET_ROOT:-/run/secrets}" +seed_root="${NEMOCLAW_MCPORTER_AUDIT_SEED_ROOT:-/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit}" secret_receipt="$secret_root/nemoclaw-mcporter-audit-receipt" secret_raw_report="$secret_root/nemoclaw-mcporter-audit-raw-report" receipt="" @@ -19,6 +20,19 @@ if [[ -e "$secret_receipt" || -L "$secret_receipt" || -e "$secret_raw_report" || fi receipt="$secret_receipt" raw_report="$secret_raw_report" +elif [[ -e "$seed_root" || -L "$seed_root" ]]; then + receipt="$seed_root/mcporter-runtime.receipt.json" + raw_report="$seed_root/mcporter-runtime.raw.json" + hash_file="$seed_root/mcporter-runtime.receipt.sha256" + if [[ ! -d "$seed_root" || -L "$seed_root" || ! -f "$receipt" || -L "$receipt" || ! -f "$raw_report" || -L "$raw_report" || ! -f "$hash_file" || -L "$hash_file" ]]; then + echo "ERROR: seed-cached mcporter audit evidence is incomplete" >&2 + exit 1 + fi + read -r receipt_sha256 <"$hash_file" + if ! printf '%s' "$receipt_sha256" | grep -qxE '[0-9a-f]{64}'; then + echo "ERROR: seed-cached mcporter audit receipt SHA-256 is invalid" >&2 + exit 1 + fi fi if [[ -z "$receipt" ]]; then diff --git a/test/install/materialize-locked-npm-cache-seed.test.ts b/test/install/materialize-locked-npm-cache-seed.test.ts index ee2bdd41f6d..e377bc95fbf 100644 --- a/test/install/materialize-locked-npm-cache-seed.test.ts +++ b/test/install/materialize-locked-npm-cache-seed.test.ts @@ -6,11 +6,13 @@ import { appendFileSync, chmodSync, existsSync, + mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, + statSync, unlinkSync, writeFileSync, } from "node:fs"; @@ -71,6 +73,22 @@ function writeLock(root: string, archives: readonly LockedArchive[]): string { return lockfile; } +function writeReviewedAuditEvidence(seed: string): { directory: string; rawReport: Buffer } { + const directory = path.join(seed, "reviewed-npm-audit"); + const rawReport = Buffer.from('{"metadata":{"vulnerabilities":{"high":0}}}\n'); + const receipt = Buffer.from( + `${JSON.stringify({ rawResponseSha256: crypto.createHash("sha256").update(rawReport).digest("hex") })}\n`, + ); + mkdirSync(directory); + writeFileSync(path.join(directory, "mcporter-runtime.raw.json"), rawReport); + writeFileSync(path.join(directory, "mcporter-runtime.receipt.json"), receipt); + writeFileSync( + path.join(directory, "mcporter-runtime.receipt.sha256"), + `${crypto.createHash("sha256").update(receipt).digest("hex")}\n`, + ); + return { directory, rawReport }; +} + let testRoot = ""; beforeEach(() => { @@ -262,6 +280,68 @@ describe("locked npm cache seed materialization", () => { ).rejects.toThrow("npm cache seed directory contains missing or unexpected files"); }); + it("copies reviewed audit evidence after bounded integrity validation (#11088)", async () => { + const alpha = archive("alpha", "alpha archive"); + const lockfile = writeLock(testRoot, [alpha.locked]); + const seed = path.join(testRoot, "seed"); + const copied = path.join(testRoot, "copied"); + await materializeLockedNpmCacheSeed({ + downloadArchive: async () => alpha.bytes, + lockfile, + output: seed, + target: TARGET, + }); + const evidence = writeReviewedAuditEvidence(seed); + + await verifyAndCopyLockedNpmCacheSeed({ lockfile, output: copied, seed, target: TARGET }); + + const copiedEvidence = path.join(copied, "reviewed-npm-audit"); + expect(readFileSync(path.join(copiedEvidence, "mcporter-runtime.raw.json"))).toEqual( + evidence.rawReport, + ); + expect(statSync(copiedEvidence).mode & 0o777).toBe(0o700); + expect(statSync(path.join(copiedEvidence, "mcporter-runtime.raw.json")).mode & 0o777).toBe( + 0o400, + ); + }); + + it.each([ + [ + "raw report", + "raw-report", + (directory: string) => + appendFileSync(path.join(directory, "mcporter-runtime.raw.json"), "changed"), + ], + [ + "receipt", + "receipt", + (directory: string) => + appendFileSync(path.join(directory, "mcporter-runtime.receipt.json"), "changed"), + ], + [ + "oversized hash", + "regular non-symlink file", + (directory: string) => + writeFileSync(path.join(directory, "mcporter-runtime.receipt.sha256"), "x".repeat(66)), + ], + ])("rejects changed reviewed audit %s evidence", async (_name, expected, mutate) => { + const alpha = archive("alpha", "alpha archive"); + const lockfile = writeLock(testRoot, [alpha.locked]); + const seed = path.join(testRoot, "seed"); + await materializeLockedNpmCacheSeed({ + downloadArchive: async () => alpha.bytes, + lockfile, + output: seed, + target: TARGET, + }); + const { directory } = writeReviewedAuditEvidence(seed); + mutate(directory); + + await expect( + verifyAndCopyLockedNpmCacheSeed({ lockfile, seed, target: TARGET }), + ).rejects.toThrow(expected); + }); + it.skipIf(process.platform === "win32")( "rejects a lock-pinned archive replaced with a symlink", async () => { diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index 4571d181ede..5a55d9d2080 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -228,8 +228,7 @@ function recordedBuildInvocations(): string[] { return readFileSync(dockerLog, "utf8") .split("\n") .filter( - (line) => - line.startsWith("buildx build ") && line.includes("io.nvidia.nemoclaw.agent="), + (line) => line.startsWith("buildx build ") && line.includes("io.nvidia.nemoclaw.agent="), ); } @@ -251,11 +250,7 @@ function recordedBuildInvocation(agent: string): string { return invocation!; } -function runBuild( - sourceRoot: string, - extraArgs: readonly string[] = [], - platform = "linux/amd64", -) { +function runBuild(sourceRoot: string, extraArgs: readonly string[] = [], platform = "linux/amd64") { const output = path.join(testRoot, "contracts.json"); return spawnSync( "bash", @@ -368,7 +363,9 @@ describe("protected managed-image build-cache boundary", () => { expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--platform linux/arm64"); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( + "--platform linux/arm64", + ); expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( "--build-arg TARGETARCH=arm64", ); @@ -407,8 +404,12 @@ describe("protected managed-image build-cache boundary", () => { expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--build-arg TARGETARCH=arm64"); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( + "--platform linux/arm64", + ); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( + "--build-arg TARGETARCH=arm64", + ); }); it("passes each agent one empty absolute cache export root", () => { @@ -468,6 +469,17 @@ describe("protected managed-image build-cache boundary", () => { "utf8", ), ).toBe(`${DIGEST}\n`); + expect( + readFileSync( + path.join( + cacheRoot, + "npm-cache-seed", + "reviewed-npm-audit", + "mcporter-runtime.receipt.sha256", + ), + "utf8", + ), + ).toBe(`${DIGEST}\n`); expect(recordedBuildInvocation("openclaw")).toContain( `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(cacheRoot)}/reviewed-npm-audit/mcporter-runtime.receipt.json`, ); @@ -563,7 +575,8 @@ describe("protected managed-image build-cache boundary", () => { it.each([ [ "missing", - (cacheRoot: string) => rmSync(path.join(cacheRoot, "reviewed-npm-audit"), { recursive: true }), + (cacheRoot: string) => + rmSync(path.join(cacheRoot, "reviewed-npm-audit"), { recursive: true }), "reviewed audit evidence is missing or unsafe", ], [ @@ -575,18 +588,21 @@ describe("protected managed-image build-cache boundary", () => { ), "reviewed audit receipt hash does not match", ], - ])("rejects %s reviewed audit evidence before invoking Docker (#11088)", (_case, mutate, error) => { - const cacheRoot = path.join(testRoot, "imported-cache"); - completeImportedCache(cacheRoot); - stubBuildInvocation(); - mutate(cacheRoot); - - const result = runBuild(REPO_ROOT, ["--cache-from", cacheRoot]); - - expect(result.status, result.stderr).toBe(1); - expect(result.stderr).toContain(error); - expect(existsSync(dockerLog)).toBe(false); - }); + ])( + "rejects %s reviewed audit evidence before invoking Docker (#11088)", + (_case, mutate, error) => { + const cacheRoot = path.join(testRoot, "imported-cache"); + completeImportedCache(cacheRoot); + stubBuildInvocation(); + mutate(cacheRoot); + + const result = runBuild(REPO_ROOT, ["--cache-from", cacheRoot]); + + expect(result.status, result.stderr).toBe(1); + expect(result.stderr).toContain(error); + expect(existsSync(dockerLog)).toBe(false); + }, + ); it("imports locked seeds, reuses safe agent caches, and disables RUN network access", () => { const cacheRoot = path.join(testRoot, "imported-cache"); diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index 5eb9d9798ba..0331500c246 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -242,10 +242,7 @@ describe("mcporter image supply-chain controls", () => { it("carries a networked reviewed audit into the offline protected OpenClaw build", () => { const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); - const producer = fs.readFileSync( - path.join(repoRoot, "Dockerfile.protected-npm-audit"), - "utf8", - ); + const producer = fs.readFileSync(path.join(repoRoot, "Dockerfile.protected-npm-audit"), "utf8"); const flattenedProducer = producer.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); expect(producer).toContain( @@ -255,13 +252,16 @@ describe("mcporter image supply-chain controls", () => { "NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH=mcporter-runtime NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR=artifacts/reviewed-npm-audit", ); expect(producer).toContain("FROM scratch AS protected-mcporter-audit-evidence"); + expect(contents).toContain("FROM scratch AS protected-mcporter-audit-cache"); expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false", ); + expect(contents).toContain("from=protected-mcporter-audit-cache"); expect(contents).toContain("bash /scripts/lib/verify-mcporter-audit.sh"); expect(mcporterAuditHelper).toContain( "cached mcporter audit requires paired receipt, raw report, and receipt SHA-256", ); + expect(mcporterAuditHelper).toContain("seed-cached mcporter audit evidence is incomplete"); expect(mcporterAuditHelper.replace(/\\\s*\n/g, " ").replace(/\s+/g, " ")).toContain( `printf '%s %s\\n' "$receipt_sha256" "$receipt" | sha256sum --check --status -`, ); From a88afed5320e8960bcd9ff24455c877c7901dbf1 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 7 Sep 2026 19:53:15 +0700 Subject: [PATCH 05/56] test: model protected audit verification helper Signed-off-by: San Dang --- test/agents/openclaw/openclaw-integrity-pin-suite.ts | 4 ++++ test/security/fetch-guard-patch-regression.test.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/test/agents/openclaw/openclaw-integrity-pin-suite.ts b/test/agents/openclaw/openclaw-integrity-pin-suite.ts index f6e217dd6c9..04e2743dc92 100644 --- a/test/agents/openclaw/openclaw-integrity-pin-suite.ts +++ b/test/agents/openclaw/openclaw-integrity-pin-suite.ts @@ -462,6 +462,10 @@ function runInstallBlock( .replaceAll("/usr/local/lib/nemoclaw/extract-semver", openclawVersionExtractor) .replaceAll("/usr/local/lib", path.join(tmp, "usr-local-lib")) .replaceAll("/usr/local/bin", path.join(tmp, "usr-local-bin")) + .replaceAll( + "bash /scripts/lib/verify-mcporter-audit.sh", + "node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", + ) .replaceAll("/scripts/lib/reviewed-npm-archive.mts", REVIEWED_NPM_ARCHIVE_HELPER) .replaceAll("/scripts/lib/openclaw-npm-remediation.mts", remediationHelper) .replaceAll("/scripts/lib/reviewed-npm-audit.mts", auditHelper) diff --git a/test/security/fetch-guard-patch-regression.test.ts b/test/security/fetch-guard-patch-regression.test.ts index 403a5bf0a4b..edd203b698a 100644 --- a/test/security/fetch-guard-patch-regression.test.ts +++ b/test/security/fetch-guard-patch-regression.test.ts @@ -186,6 +186,10 @@ function runOpenClawUpgradeBlock(currentVersion: string) { "# OPENCLAW_VERSION is the NemoClaw runtime build target", "# Patch OpenClaw media fetch", ) + .replaceAll( + "bash /scripts/lib/verify-mcporter-audit.sh", + "node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", + ) .replaceAll("/opt/nemoclaw-blueprint/blueprint.yaml", blueprint) .replaceAll("/usr/local/lib/node_modules/openclaw", openclawInstall) .replaceAll( From 46f28b55fbaa44b9a5ee40751c2cb933113df812 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 7 Sep 2026 22:22:39 +0700 Subject: [PATCH 06/56] fix(security): pin protected audit evidence paths Signed-off-by: San Dang --- scripts/lib/verify-mcporter-audit.sh | 4 ++-- test/security/mcporter-supply-chain.test.ts | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/lib/verify-mcporter-audit.sh b/scripts/lib/verify-mcporter-audit.sh index 2726b8409e2..2435c55ffc9 100755 --- a/scripts/lib/verify-mcporter-audit.sh +++ b/scripts/lib/verify-mcporter-audit.sh @@ -4,8 +4,8 @@ set -euo pipefail -secret_root="${NEMOCLAW_MCPORTER_AUDIT_SECRET_ROOT:-/run/secrets}" -seed_root="${NEMOCLAW_MCPORTER_AUDIT_SEED_ROOT:-/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit}" +secret_root="/run/secrets" +seed_root="/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit" secret_receipt="$secret_root/nemoclaw-mcporter-audit-receipt" secret_raw_report="$secret_root/nemoclaw-mcporter-audit-raw-report" receipt="" diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index 0331500c246..1ead31577e1 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -258,6 +258,12 @@ describe("mcporter image supply-chain controls", () => { ); expect(contents).toContain("from=protected-mcporter-audit-cache"); expect(contents).toContain("bash /scripts/lib/verify-mcporter-audit.sh"); + expect(mcporterAuditHelper).toContain('secret_root="/run/secrets"'); + expect(mcporterAuditHelper).toContain( + 'seed_root="/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit"', + ); + expect(mcporterAuditHelper).not.toContain("NEMOCLAW_MCPORTER_AUDIT_SECRET_ROOT"); + expect(mcporterAuditHelper).not.toContain("NEMOCLAW_MCPORTER_AUDIT_SEED_ROOT"); expect(mcporterAuditHelper).toContain( "cached mcporter audit requires paired receipt, raw report, and receipt SHA-256", ); From cd32fc168b8ded5857b5d653bd409456a3da18bd Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 8 Sep 2026 12:02:48 +0700 Subject: [PATCH 07/56] refactor: reduce protected audit handoff Signed-off-by: San Dang --- Dockerfile | 26 ++++-- agents/openclaw/dependency-review.md | 1 + .../checks/build-protected-managed-images.sh | 4 - .../materialize-locked-npm-cache-seed.mts | 70 +--------------- scripts/lib/reviewed-npm-audit.mts | 60 +++++--------- scripts/lib/verify-mcporter-audit.sh | 54 ------------- src/lib/sandbox/build-context.ts | 4 - .../openclaw/openclaw-integrity-pin-suite.ts | 4 - .../reviewed-npm-audit-workflow.test.ts | 2 +- .../releases/reviewed-npm-audit.test.ts | 42 ++++------ .../materialize-locked-npm-cache-seed.test.ts | 80 ------------------- ...otected-managed-image-build-script.test.ts | 29 ++----- .../sandbox/sandbox-build-context.test.ts | 6 -- .../fetch-guard-patch-regression.test.ts | 4 - test/security/mcporter-supply-chain.test.ts | 33 ++------ 15 files changed, 74 insertions(+), 345 deletions(-) delete mode 100755 scripts/lib/verify-mcporter-audit.sh diff --git a/Dockerfile b/Dockerfile index f28044637cc..cf22361afed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -546,7 +546,6 @@ COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts -COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -600,11 +599,6 @@ COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/ COPY --from=mcp-tool-discovery-runtime /opt/mcp-tool-discovery-runtime/dist/ /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime/ -# Protected consumers import reviewed audit evidence through the existing -# locked seed because their build driver comes from trusted main. -FROM scratch AS protected-mcporter-audit-cache -COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/ /seed/ - # Stage 3: Runtime image — pull cached base from GHCR # hadolint ignore=DL3006 FROM ${BASE_IMAGE} @@ -829,7 +823,6 @@ RUN command -v codex-acp >/dev/null RUN --network=default \ --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ - --mount=type=bind,from=protected-mcporter-audit-cache,source=/seed,target=/run/nemoclaw-mcporter-audit-cache \ set -eu; \ if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ @@ -993,7 +986,24 @@ RUN --network=default \ ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter; \ test "$(mcporter --version)" = "$MCPORTER_VERSION"; \ fi; \ - bash /scripts/lib/verify-mcporter-audit.sh + MCPORTER_RECEIPT=/run/secrets/nemoclaw-mcporter-audit-receipt; \ + MCPORTER_RAW_REPORT=/run/secrets/nemoclaw-mcporter-audit-raw-report; \ + if [ -f "$MCPORTER_RECEIPT" ] || [ -f "$MCPORTER_RAW_REPORT" ] || [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]; then \ + [ -f "$MCPORTER_RECEIPT" ] && [ -f "$MCPORTER_RAW_REPORT" ] && printf %s "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}' \ + || { echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2; exit 1; }; \ + printf '%s %s\n' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" "$MCPORTER_RECEIPT" | sha256sum -c -; \ +node --experimental-strip-types /scripts/lib/npm-audit-receipt.mts \ +--receipt "$MCPORTER_RECEIPT" \ +--package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ +--package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ +--raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ +--graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ +--registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true; \ + else \ + node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ + --directory /usr/local/lib/nemoclaw/mcporter-runtime \ + --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high; \ + fi # Patch OpenClaw media fetch for proxy-only sandbox (NVIDIA/NemoClaw#1755). # diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index 39299a736d8..6afef43b8dd 100644 --- a/agents/openclaw/dependency-review.md +++ b/agents/openclaw/dependency-review.md @@ -28,6 +28,7 @@ Update it and `agents/openclaw/mcporter-runtime/package*.json` together whenever Both image paths install the committed graph with `npm ci --ignore-scripts --omit=dev` because the published package declares no install-time lifecycle script and NemoClaw needs only its already-built CLI. The reviewed audit wrapper reports lower-severity production findings and blocks unaccepted high or critical advisories. The default `ci/npm-audit-exceptions.json` registry is empty. Any future exception must match one advisory, graph, package, installed version, and severity; identify an owner and NemoClaw tracking issue; state a decision, rationale, and expiry no more than 30 days away; and include compensating controls for temporary risk acceptance. Missing, malformed, expired, overlong, mismatched, or unused exceptions fail closed. The repository-wide audit also rejects exceptions for unknown graph IDs. Registry signature verification remains a separate control. +Managed image publication supplies the mcporter receipt and raw report as BuildKit secrets. Protected qualification produces the same evidence during its networked cache export and carries it alongside that cache into the offline rebuild. A build without either evidence source runs the reviewed audit directly and fails closed if completeness cannot be established. ## WeChat plugin runtime graph diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index f42df3545a1..be132b2ebe1 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -547,10 +547,6 @@ if [[ -n "$cache_to" ]]; then --os "$npm_target_os" \ --cpu "$npm_target_cpu" \ --libc "$npm_target_libc" - install -d -m 0700 "$cache_to/npm-cache-seed/reviewed-npm-audit" - install -m 0400 \ - "$audit_receipt" "$audit_raw_report" "$audit_evidence_dir/mcporter-runtime.receipt.sha256" \ - "$cache_to/npm-cache-seed/reviewed-npm-audit/" node --experimental-strip-types --no-warnings "$seed_helper" export \ --lockfile "$source_mcp_lockfile" \ --output "$cache_to/mcp-runtime-npm-cache-seed" \ diff --git a/scripts/checks/materialize-locked-npm-cache-seed.mts b/scripts/checks/materialize-locked-npm-cache-seed.mts index 29da656ebd1..c446f44be70 100644 --- a/scripts/checks/materialize-locked-npm-cache-seed.mts +++ b/scripts/checks/materialize-locked-npm-cache-seed.mts @@ -7,7 +7,6 @@ import { chmod, copyFile, lstat, - mkdir, mkdtemp, open, readdir, @@ -23,12 +22,6 @@ const MANIFEST_KIND = "nemoclaw-locked-npm-cache-seed-v1"; const MANIFEST_NAME = "manifest.json"; const REGISTRY_ORIGIN = "https://registry.npmjs.org"; const MAX_ARCHIVE_BYTES = 32 * 1024 * 1024; -const REVIEWED_AUDIT_DIRECTORY = "reviewed-npm-audit"; -const REVIEWED_AUDIT_FILES = [ - { maxBytes: 64 * 1024 * 1024, name: "mcporter-runtime.raw.json" }, - { maxBytes: 64 * 1024, name: "mcporter-runtime.receipt.json" }, - { maxBytes: 65, name: "mcporter-runtime.receipt.sha256" }, -] as const; const DOWNLOAD_CONCURRENCY = 6; const DOWNLOAD_ATTEMPTS = 4; const DOWNLOAD_TIMEOUT_MS = 30_000; @@ -100,46 +93,6 @@ function lockSha256(source: Uint8Array): string { return crypto.createHash("sha256").update(source).digest("hex"); } -async function reviewedAuditEvidence(seed: string): Promise { - const directory = path.join(seed, REVIEWED_AUDIT_DIRECTORY); - let status; - try { - status = await lstat(directory); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; - throw error; - } - if (!status.isDirectory() || status.isSymbolicLink()) { - throw new Error("reviewed npm audit evidence must be one non-symlink directory"); - } - const entries = await readdir(directory, { withFileTypes: true }); - if ( - entries.some((entry) => !entry.isFile() || entry.isSymbolicLink()) || - JSON.stringify(entries.map(({ name }) => name).sort()) !== - JSON.stringify(REVIEWED_AUDIT_FILES.map(({ name }) => name).sort()) - ) { - throw new Error("reviewed npm audit evidence contains missing or unexpected files"); - } - const evidence = await Promise.all( - REVIEWED_AUDIT_FILES.map(({ maxBytes, name }) => - exactFileSource(path.join(directory, name), `reviewed npm audit ${name}`, maxBytes), - ), - ); - const [rawReport, receipt, receiptHash] = evidence; - if ( - !rawReport?.length || - !receipt?.length || - receiptHash?.toString("utf8") !== `${lockSha256(receipt)}\n` - ) { - throw new Error("reviewed npm audit evidence failed receipt integrity validation"); - } - const parsedReceipt = record(JSON.parse(receipt.toString("utf8")), "reviewed npm audit receipt"); - if (parsedReceipt.rawResponseSha256 !== lockSha256(rawReport)) { - throw new Error("reviewed npm audit evidence failed raw-report integrity validation"); - } - return evidence; -} - function archiveIntegrity(source: Uint8Array): string { return `sha512-${crypto.createHash("sha512").update(source).digest("base64")}`; } @@ -298,7 +251,7 @@ export function lockedArchives( return [...byArchive.values()].sort((left, right) => left.archive.localeCompare(right.archive)); } -async function exactFileSource(file: string, label: string, maxBytes?: number): Promise { +async function exactFileSource(file: string, label: string): Promise { if (!path.isAbsolute(file) || file.includes("\n")) { throw new Error(`${label} must be an absolute path`); } @@ -307,7 +260,7 @@ async function exactFileSource(file: string, label: string, maxBytes?: number): }); try { const status = await handle.stat(); - if (!status.isFile() || (maxBytes !== undefined && status.size > maxBytes)) { + if (!status.isFile()) { throw new Error(`${label} must be one regular non-symlink file`); } return await handle.readFile(); @@ -508,7 +461,6 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { const target = exactTarget(options.target); const expected = lockedArchives(lockSource.toString("utf8"), target); const seed = await exactDirectory(options.seed, "seed directory"); - const auditEvidence = await reviewedAuditEvidence(seed); const manifestSource = await exactFileSource(path.join(seed, MANIFEST_NAME), "seed manifest"); const manifest = parseManifest(manifestSource.toString("utf8")); if (manifest.lockSha256 !== lockSha256(lockSource)) { @@ -525,11 +477,7 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { throw new Error("npm cache seed manifest does not contain the complete locked archive set"); } const entries = await readdir(seed, { withFileTypes: true }); - const expectedNames = [ - ...expected.map(({ archive }) => archive), - MANIFEST_NAME, - ...(auditEvidence ? [REVIEWED_AUDIT_DIRECTORY] : []), - ].sort(); + const expectedNames = [...expected.map(({ archive }) => archive), MANIFEST_NAME].sort(); const actualNames = entries.map(({ name }) => name).sort(); if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) { throw new Error("npm cache seed directory contains missing or unexpected files"); @@ -554,18 +502,6 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { await copyFile(path.join(seed, archive.archive), destination); await chmod(destination, 0o444); } - if (auditEvidence) { - const auditDirectory = path.join(directory.temporary, REVIEWED_AUDIT_DIRECTORY); - await mkdir(auditDirectory, { mode: 0o700 }); - await Promise.all( - REVIEWED_AUDIT_FILES.map(({ name }, index) => - writeFile(path.join(auditDirectory, name), auditEvidence[index], { - flag: "wx", - mode: 0o400, - }), - ), - ); - } await directory.commit(); } catch (error) { await rm(directory.temporary, { force: true, recursive: true }); diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index d28f262f167..793178f6fd8 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -152,15 +152,7 @@ export type NpmAuditResponseClassification = | Readonly<{ failure: NpmAuditFailureClassification }> | Readonly<{ report: Record }>; -const TRANSIENT_TRANSPORT_CODES = [ - "EAI_AGAIN", - "ECONNREFUSED", - "ECONNRESET", - "EHOSTUNREACH", - "ENETUNREACH", - "ENOTFOUND", - "ETIMEDOUT", -] as const; +const RETRYABLE_TRANSPORT_CODE = "ECONNRESET"; function asRecord(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -307,11 +299,6 @@ function valueShape(value: unknown): string { if (value === undefined) return "missing"; if (value === null) return "null"; if (Array.isArray(value)) return "array"; - if (typeof value === "number") { - if (!Number.isFinite(value)) return "non-finite-number"; - if (!Number.isSafeInteger(value)) return "non-integer-number"; - if (value < 0) return "negative-number"; - } return typeof value; } @@ -339,36 +326,20 @@ function firstInvalidAuditField(value: unknown): string | undefined { for (const severity of SEVERITIES) { const count = vulnerabilities[severity]; if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) { - return `metadata.vulnerabilities.${severity}:${valueShape(count)}`; + return `metadata.vulnerabilities.${severity}:${typeof count === "number" ? "invalid-number" : valueShape(count)}`; } } return undefined; } -function transportCode(report: Record, stderr: string): string | undefined { +function hasRetryableTransportError(report: Record, stderr: string): boolean { const error = typeof report.error === "object" && report.error !== null && !Array.isArray(report.error) ? (report.error as Record) : {}; - const evidence = [report.message, error.code, error.summary, error.detail, stderr] + return [report.message, error.code, error.summary, error.detail, stderr] .filter((value): value is string => typeof value === "string") - .join("\n"); - return TRANSIENT_TRANSPORT_CODES.find((code) => - new RegExp(`(?:^|[^A-Z0-9_])${code}(?:$|[^A-Z0-9_])`, "u").test(evidence), - ); -} - -function responseEvidence( - result: Readonly<{ status: number | null; stdout: string }>, - fields: readonly string[], -): string { - const status = result.status === null ? "null" : String(result.status); - return [ - `exit=${status}`, - `stdout-bytes=${Buffer.byteLength(result.stdout)}`, - `stdout-sha256=${sha256(result.stdout)}`, - ...fields, - ].join(" "); + .some((value) => /(?:^|[^A-Z0-9_])ECONNRESET(?:$|[^A-Z0-9_])/u.test(value)); } function rejectedAuditResponse( @@ -377,9 +348,16 @@ function rejectedAuditResponse( retryable: boolean, fields: readonly string[] = [], ): NpmAuditResponseClassification { + const status = result.status === null ? "null" : String(result.status); return { failure: { - diagnostic: responseEvidence(result, [`condition=${reason}`, ...fields]), + diagnostic: [ + `exit=${status}`, + `stdout-bytes=${Buffer.byteLength(result.stdout)}`, + `stdout-sha256=${sha256(result.stdout)}`, + `condition=${reason}`, + ...fields, + ].join(" "), reason, retryable, }, @@ -392,13 +370,13 @@ export function classifyNpmAuditResponse(result: { stderr: string; stdout: string; }): NpmAuditResponseClassification { - if (!result.stdout.trim()) return rejectedAuditResponse(result, "empty-output", true); + if (!result.stdout.trim()) return rejectedAuditResponse(result, "empty-output", false); let value: unknown; try { value = JSON.parse(result.stdout); } catch { - return rejectedAuditResponse(result, "invalid-json", true); + return rejectedAuditResponse(result, "invalid-json", false); } if (value === null || typeof value !== "object" || Array.isArray(value)) { @@ -409,10 +387,10 @@ export function classifyNpmAuditResponse(result: { const report = value as Record; const invalidField = firstInvalidAuditField(report); if (report.error !== undefined) { - const code = transportCode(report, result.stderr); - const reason = code ? "registry-network-error" : "npm-error-document"; - return rejectedAuditResponse(result, reason, code !== undefined, [ - ...(code ? [`transport=${code}`] : []), + const retryable = hasRetryableTransportError(report, result.stderr); + const reason = retryable ? "registry-network-error" : "npm-error-document"; + return rejectedAuditResponse(result, reason, retryable, [ + ...(retryable ? [`transport=${RETRYABLE_TRANSPORT_CODE}`] : []), ...(invalidField ? [`required-field=${invalidField}`] : []), ]); } diff --git a/scripts/lib/verify-mcporter-audit.sh b/scripts/lib/verify-mcporter-audit.sh deleted file mode 100755 index 2435c55ffc9..00000000000 --- a/scripts/lib/verify-mcporter-audit.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -set -euo pipefail - -secret_root="/run/secrets" -seed_root="/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit" -secret_receipt="$secret_root/nemoclaw-mcporter-audit-receipt" -secret_raw_report="$secret_root/nemoclaw-mcporter-audit-raw-report" -receipt="" -raw_report="" -receipt_sha256="${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" - -if [[ -e "$secret_receipt" || -L "$secret_receipt" || -e "$secret_raw_report" || -L "$secret_raw_report" || -n "$receipt_sha256" ]]; then - if [[ ! -f "$secret_receipt" || -L "$secret_receipt" || ! -f "$secret_raw_report" || -L "$secret_raw_report" ]] \ - || ! printf '%s' "$receipt_sha256" | grep -qxE '[0-9a-f]{64}'; then - echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2 - exit 1 - fi - receipt="$secret_receipt" - raw_report="$secret_raw_report" -elif [[ -e "$seed_root" || -L "$seed_root" ]]; then - receipt="$seed_root/mcporter-runtime.receipt.json" - raw_report="$seed_root/mcporter-runtime.raw.json" - hash_file="$seed_root/mcporter-runtime.receipt.sha256" - if [[ ! -d "$seed_root" || -L "$seed_root" || ! -f "$receipt" || -L "$receipt" || ! -f "$raw_report" || -L "$raw_report" || ! -f "$hash_file" || -L "$hash_file" ]]; then - echo "ERROR: seed-cached mcporter audit evidence is incomplete" >&2 - exit 1 - fi - read -r receipt_sha256 <"$hash_file" - if ! printf '%s' "$receipt_sha256" | grep -qxE '[0-9a-f]{64}'; then - echo "ERROR: seed-cached mcporter audit receipt SHA-256 is invalid" >&2 - exit 1 - fi -fi - -if [[ -z "$receipt" ]]; then - exec node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ - --directory /usr/local/lib/nemoclaw/mcporter-runtime \ - --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high -fi - -printf '%s %s\n' "$receipt_sha256" "$receipt" | sha256sum --check --status - || { - echo "ERROR: cached mcporter audit receipt hash does not match" >&2 - exit 1 -} -exec node --experimental-strip-types /scripts/lib/npm-audit-receipt.mts \ - --receipt "$receipt" \ - --package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ - --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ - --raw-report "$raw_report" --exceptions /scripts/npm-audit-exceptions.json \ - --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ - --registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 41ff15a5ba1..e0e96f7a55b 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -461,10 +461,6 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "lib", "openclaw-npm-remediation.mts"), path.join(stagedScriptsDir, "lib", "openclaw-npm-remediation.mts"), ); - fs.copyFileSync( - path.join(rootDir, "scripts", "lib", "verify-mcporter-audit.sh"), - path.join(stagedScriptsDir, "lib", "verify-mcporter-audit.sh"), - ); normalizeReadModesForDockerCopy(stagedScriptsDir); return { buildCtx, stagedDockerfile }; diff --git a/test/agents/openclaw/openclaw-integrity-pin-suite.ts b/test/agents/openclaw/openclaw-integrity-pin-suite.ts index 04e2743dc92..f6e217dd6c9 100644 --- a/test/agents/openclaw/openclaw-integrity-pin-suite.ts +++ b/test/agents/openclaw/openclaw-integrity-pin-suite.ts @@ -462,10 +462,6 @@ function runInstallBlock( .replaceAll("/usr/local/lib/nemoclaw/extract-semver", openclawVersionExtractor) .replaceAll("/usr/local/lib", path.join(tmp, "usr-local-lib")) .replaceAll("/usr/local/bin", path.join(tmp, "usr-local-bin")) - .replaceAll( - "bash /scripts/lib/verify-mcporter-audit.sh", - "node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", - ) .replaceAll("/scripts/lib/reviewed-npm-archive.mts", REVIEWED_NPM_ARCHIVE_HELPER) .replaceAll("/scripts/lib/openclaw-npm-remediation.mts", remediationHelper) .replaceAll("/scripts/lib/reviewed-npm-audit.mts", auditHelper) diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index a1f5c260ef0..8312cd1ab2d 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -409,7 +409,7 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { ["malformed npm output", "{not-json", 1, /invalid-json/], [ "parseable npm error JSON", - JSON.stringify({ error: { code: "ECONNREFUSED", summary: "registry unreachable" } }), + JSON.stringify({ error: { summary: "registry request failed: ECONNRESET" } }), 1, /registry-network-error/, ], diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index ed8dbd9f317..896869b39f2 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -187,25 +187,13 @@ describe("reviewed npm audit gate", () => { ).toEqual({ report }); }); - it("rejects a parseable npm transport failure instead of treating it as clean", () => { - expect(() => - parseAuditReport({ - status: 1, - stderr: "npm registry unavailable", - stdout: JSON.stringify({ - error: { code: "ECONNREFUSED", summary: "request to registry failed" }, - }), - }), - ).toThrow(/registry-network-error.*ECONNREFUSED/); - }); - it("classifies npm 11.18.0's observed registry error document without exposing its message (#11088)", () => { const secret = "https://audit-user:registry-secret@registry.example/private"; const classified = classifyNpmAuditResponse({ status: 1, stderr: `authorization: Bearer stderr-secret for ${secret}`, stdout: JSON.stringify({ - message: `request to ${secret} failed, reason: connect ECONNREFUSED 127.0.0.1:9`, + message: `request to ${secret} failed, reason: read ECONNRESET`, error: { summary: "", detail: "" }, }), }); @@ -213,7 +201,7 @@ describe("reviewed npm audit gate", () => { expect(classified).toEqual({ failure: { diagnostic: expect.stringMatching( - /^exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=ECONNREFUSED required-field=metadata:missing$/, + /^exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=ECONNRESET required-field=metadata:missing$/, ), reason: "registry-network-error", retryable: true, @@ -245,12 +233,12 @@ describe("reviewed npm audit gate", () => { failure: { diagnostic: expect.stringContaining(`condition=${reason}`), reason, - retryable: true, + retryable: false, }, }); }); - it("retries scan-incomplete npm responses with bounded backoff", () => { + it("retries only the observed registry reset with bounded backoff", () => { const completeReport = { metadata: { vulnerabilities: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, @@ -259,7 +247,11 @@ describe("reviewed npm audit gate", () => { const sensitiveStderr = "request failed for https://audit-user:secret-token@registry.example/\n\u001b[31mstderr detail"; const responses = [ - { status: 1, stderr: sensitiveStderr, stdout: "" }, + { + status: 1, + stderr: sensitiveStderr, + stdout: JSON.stringify({ message: "read ECONNRESET", error: { summary: "" } }), + }, { status: 0, stderr: "", stdout: JSON.stringify(completeReport) }, ]; const delays: number[] = []; @@ -276,7 +268,7 @@ describe("reviewed npm audit gate", () => { expect(delays).toEqual([1_000]); expect(warnings).toEqual([ expect.stringMatching( - /^npm audit scan failed on attempt 1\/2; retrying in 1000 ms \(reason=empty-output; exit=1 stdout-bytes=0 stdout-sha256=[a-f0-9]{64} condition=empty-output\)$/, + /^npm audit scan failed on attempt 1\/2; retrying in 1000 ms \(reason=registry-network-error; exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=ECONNRESET required-field=metadata:missing\)$/, ), ]); const warningOutput = warnings.join("\n"); @@ -416,7 +408,7 @@ describe("reviewed npm audit gate", () => { status: 1, stderr: "registry-token=terminal-stderr-secret", stdout: JSON.stringify({ - message: "request failed with EAI_AGAIN and terminal-message-secret", + message: "request failed with ECONNRESET and terminal-message-secret", error: { summary: "registry-token=terminal-summary-secret", detail: "authorization: bearer terminal-detail-secret", @@ -432,7 +424,7 @@ describe("reviewed npm audit gate", () => { expect(delays).toEqual([1_000]); expect(audit.report).toBeUndefined(); expect(audit.failure?.message).toMatch( - /^npm audit scan failed after 2 attempts \(reason=registry-network-error; exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=EAI_AGAIN required-field=metadata:missing\)$/, + /^npm audit scan failed after 2 attempts \(reason=registry-network-error; exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=ECONNRESET required-field=metadata:missing\)$/, ); expect(audit.failure?.message).not.toContain("terminal-stderr-secret"); expect(audit.failure?.message).not.toContain("terminal-summary-secret"); @@ -818,7 +810,7 @@ describe("reviewed npm audit provenance", () => { [ "#!/bin/sh", 'test "$1" = "audit" && {', - ' echo \'{"message":"request to https://audit-user:secret-token@registry.example failed: ECONNREFUSED","error":{"code":"ECONNREFUSED","summary":"registry unreachable"}}\'', + ' echo \'{"message":"request to https://audit-user:secret-token@registry.example failed: ECONNRESET","error":{"summary":"registry unreachable"}}\'', " exit 1", "}", "exit 7", @@ -842,21 +834,21 @@ describe("reviewed npm audit provenance", () => { reportFile: reportPath, threshold: "high", }), - ).toThrow(/failed after 2 attempts.*registry-network-error.*transport=ECONNREFUSED/); + ).toThrow(/failed after 2 attempts.*registry-network-error.*transport=ECONNRESET/); const sidecar = JSON.parse( fs.readFileSync(path.join(tempRoot, "graph.provenance.json"), "utf-8"), ) as Record; expect(sidecar.failure).toMatch( - /^npm audit scan failed after 2 attempts \(reason=registry-network-error; exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=ECONNREFUSED required-field=metadata:missing\)$/, + /^npm audit scan failed after 2 attempts \(reason=registry-network-error; exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=ECONNRESET required-field=metadata:missing\)$/, ); - expect(sidecar.failure).toContain("ECONNREFUSED"); + expect(sidecar.failure).toContain("ECONNRESET"); expect(sidecar.failure).not.toContain("registry unreachable"); expect(sidecar.advisoryIds).toEqual([]); expect(sidecar.rawReportPath).toBe("graph.json"); expect(sidecar.registry).toEqual(deriveAuditEndpoints("https://registry.yarnpkg.com")); const retainedFailure = fs.readFileSync(reportPath, "utf8"); expect(retainedFailure).toMatch(/"reason": "registry-network-error"/); - expect(retainedFailure).toContain("transport=ECONNREFUSED"); + expect(retainedFailure).toContain("transport=ECONNRESET"); expect(retainedFailure).not.toContain("audit-user"); expect(retainedFailure).not.toContain("secret-token"); expect(retainedFailure).not.toContain("registry.example"); diff --git a/test/install/materialize-locked-npm-cache-seed.test.ts b/test/install/materialize-locked-npm-cache-seed.test.ts index e377bc95fbf..ee2bdd41f6d 100644 --- a/test/install/materialize-locked-npm-cache-seed.test.ts +++ b/test/install/materialize-locked-npm-cache-seed.test.ts @@ -6,13 +6,11 @@ import { appendFileSync, chmodSync, existsSync, - mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, - statSync, unlinkSync, writeFileSync, } from "node:fs"; @@ -73,22 +71,6 @@ function writeLock(root: string, archives: readonly LockedArchive[]): string { return lockfile; } -function writeReviewedAuditEvidence(seed: string): { directory: string; rawReport: Buffer } { - const directory = path.join(seed, "reviewed-npm-audit"); - const rawReport = Buffer.from('{"metadata":{"vulnerabilities":{"high":0}}}\n'); - const receipt = Buffer.from( - `${JSON.stringify({ rawResponseSha256: crypto.createHash("sha256").update(rawReport).digest("hex") })}\n`, - ); - mkdirSync(directory); - writeFileSync(path.join(directory, "mcporter-runtime.raw.json"), rawReport); - writeFileSync(path.join(directory, "mcporter-runtime.receipt.json"), receipt); - writeFileSync( - path.join(directory, "mcporter-runtime.receipt.sha256"), - `${crypto.createHash("sha256").update(receipt).digest("hex")}\n`, - ); - return { directory, rawReport }; -} - let testRoot = ""; beforeEach(() => { @@ -280,68 +262,6 @@ describe("locked npm cache seed materialization", () => { ).rejects.toThrow("npm cache seed directory contains missing or unexpected files"); }); - it("copies reviewed audit evidence after bounded integrity validation (#11088)", async () => { - const alpha = archive("alpha", "alpha archive"); - const lockfile = writeLock(testRoot, [alpha.locked]); - const seed = path.join(testRoot, "seed"); - const copied = path.join(testRoot, "copied"); - await materializeLockedNpmCacheSeed({ - downloadArchive: async () => alpha.bytes, - lockfile, - output: seed, - target: TARGET, - }); - const evidence = writeReviewedAuditEvidence(seed); - - await verifyAndCopyLockedNpmCacheSeed({ lockfile, output: copied, seed, target: TARGET }); - - const copiedEvidence = path.join(copied, "reviewed-npm-audit"); - expect(readFileSync(path.join(copiedEvidence, "mcporter-runtime.raw.json"))).toEqual( - evidence.rawReport, - ); - expect(statSync(copiedEvidence).mode & 0o777).toBe(0o700); - expect(statSync(path.join(copiedEvidence, "mcporter-runtime.raw.json")).mode & 0o777).toBe( - 0o400, - ); - }); - - it.each([ - [ - "raw report", - "raw-report", - (directory: string) => - appendFileSync(path.join(directory, "mcporter-runtime.raw.json"), "changed"), - ], - [ - "receipt", - "receipt", - (directory: string) => - appendFileSync(path.join(directory, "mcporter-runtime.receipt.json"), "changed"), - ], - [ - "oversized hash", - "regular non-symlink file", - (directory: string) => - writeFileSync(path.join(directory, "mcporter-runtime.receipt.sha256"), "x".repeat(66)), - ], - ])("rejects changed reviewed audit %s evidence", async (_name, expected, mutate) => { - const alpha = archive("alpha", "alpha archive"); - const lockfile = writeLock(testRoot, [alpha.locked]); - const seed = path.join(testRoot, "seed"); - await materializeLockedNpmCacheSeed({ - downloadArchive: async () => alpha.bytes, - lockfile, - output: seed, - target: TARGET, - }); - const { directory } = writeReviewedAuditEvidence(seed); - mutate(directory); - - await expect( - verifyAndCopyLockedNpmCacheSeed({ lockfile, seed, target: TARGET }), - ).rejects.toThrow(expected); - }); - it.skipIf(process.platform === "win32")( "rejects a lock-pinned archive replaced with a symlink", async () => { diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index 5a55d9d2080..23a2cfe8a30 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -250,7 +250,11 @@ function recordedBuildInvocation(agent: string): string { return invocation!; } -function runBuild(sourceRoot: string, extraArgs: readonly string[] = [], platform = "linux/amd64") { +function runBuild( + sourceRoot: string, + extraArgs: readonly string[] = [], + platform = "linux/amd64", +) { const output = path.join(testRoot, "contracts.json"); return spawnSync( "bash", @@ -363,9 +367,7 @@ describe("protected managed-image build-cache boundary", () => { expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( - "--platform linux/arm64", - ); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--platform linux/arm64"); expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( "--build-arg TARGETARCH=arm64", ); @@ -404,12 +406,8 @@ describe("protected managed-image build-cache boundary", () => { expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( - "--platform linux/arm64", - ); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( - "--build-arg TARGETARCH=arm64", - ); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--platform linux/arm64"); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--build-arg TARGETARCH=arm64"); }); it("passes each agent one empty absolute cache export root", () => { @@ -469,17 +467,6 @@ describe("protected managed-image build-cache boundary", () => { "utf8", ), ).toBe(`${DIGEST}\n`); - expect( - readFileSync( - path.join( - cacheRoot, - "npm-cache-seed", - "reviewed-npm-audit", - "mcporter-runtime.receipt.sha256", - ), - "utf8", - ), - ).toBe(`${DIGEST}\n`); expect(recordedBuildInvocation("openclaw")).toContain( `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(cacheRoot)}/reviewed-npm-audit/mcporter-runtime.receipt.json`, ); diff --git a/test/runtime/sandbox/sandbox-build-context.test.ts b/test/runtime/sandbox/sandbox-build-context.test.ts index 0cb2cf451c4..b4dc2a1a1e5 100644 --- a/test/runtime/sandbox/sandbox-build-context.test.ts +++ b/test/runtime/sandbox/sandbox-build-context.test.ts @@ -291,7 +291,6 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "lib", "reviewed-npm-audit.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "npm-audit-receipt.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "openclaw-npm-remediation.mts"), "fixture\n", 0o700); - writeFixture(path.join("scripts", "lib", "verify-mcporter-audit.sh"), "fixture\n", 0o700); fs.chmodSync(path.join(sourceRoot, "scripts"), 0o700); fs.chmodSync(path.join(sourceRoot, "scripts", "lib"), 0o700); } @@ -550,11 +549,6 @@ describe("sandbox build context staging", () => { ); expect((fs.statSync(stagedFile).mode & 0o777).toString(8)).toBe("644"); } - for (const relativePath of [path.join("scripts", "lib", "verify-mcporter-audit.sh")]) { - expect(fs.readFileSync(path.join(buildCtx, relativePath), "utf8")).toBe( - fs.readFileSync(path.join(sourceRoot, relativePath), "utf8"), - ); - } } it("normalizes restrictive and group-writable modes for Docker COPY", () => { diff --git a/test/security/fetch-guard-patch-regression.test.ts b/test/security/fetch-guard-patch-regression.test.ts index edd203b698a..403a5bf0a4b 100644 --- a/test/security/fetch-guard-patch-regression.test.ts +++ b/test/security/fetch-guard-patch-regression.test.ts @@ -186,10 +186,6 @@ function runOpenClawUpgradeBlock(currentVersion: string) { "# OPENCLAW_VERSION is the NemoClaw runtime build target", "# Patch OpenClaw media fetch", ) - .replaceAll( - "bash /scripts/lib/verify-mcporter-audit.sh", - "node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", - ) .replaceAll("/opt/nemoclaw-blueprint/blueprint.yaml", blueprint) .replaceAll("/usr/local/lib/node_modules/openclaw", openclawInstall) .replaceAll( diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index 1ead31577e1..64a22fc3141 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -47,11 +47,6 @@ const reviewedAuditDriver = fs.readFileSync( path.join(repoRoot, "scripts", "audit-reviewed-npm-graph.mts"), "utf8", ); -const mcporterAuditHelper = fs.readFileSync( - path.join(repoRoot, "scripts", "lib", "verify-mcporter-audit.sh"), - "utf8", -); - function extractIntegrityGate(contents: string): string { const startMarker = 'MCPORTER_EXPECTED_INTEGRITY=""'; const start = contents.indexOf(startMarker); @@ -193,18 +188,17 @@ describe("mcporter image supply-chain controls", () => { expect(unpinned.stdout).not.toContain("gate-passed"); }); - it.each(dockerfiles)("audits the committed dependency graph in $name", ({ name, contents }) => { - const auditContents = name === "Dockerfile" ? `${contents}\n${mcporterAuditHelper}` : contents; - const flattenedContents = auditContents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); - const auditReceiptInvocation = extractAuditReceiptInvocation(auditContents); - expect(auditContents).toContain( + it.each(dockerfiles)("audits the committed dependency graph in $name", ({ contents }) => { + const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + const auditReceiptInvocation = extractAuditReceiptInvocation(contents); + expect(contents).toContain( "COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/", ); expect( flattenedContents.includes( "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/bundled-npm-package.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/openclaw-npm-remediation.mts /scripts/lib/", ) || - auditContents.includes( + contents.includes( "COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts", ), ).toBe(true); @@ -229,7 +223,7 @@ describe("mcporter image supply-chain controls", () => { ); expect(expectedReviewedNpmVersion).toMatch(/^[0-9]+\.[0-9]+\.[0-9]+$/); expect(auditReceiptInvocation).not.toContain("--npm-version"); - expect(auditContents).not.toContain("--raw-copy"); + expect(contents).not.toContain("--raw-copy"); expect(auditReceiptInvocation).not.toMatch(/\bnpm\s+--version\b/); expect(auditReceiptInvocation).not.toMatch(/\$\(|`/); expect(contents).not.toContain(`${runtimePrefix} audit --omit=dev --audit-level=low`); @@ -252,25 +246,12 @@ describe("mcporter image supply-chain controls", () => { "NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH=mcporter-runtime NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR=artifacts/reviewed-npm-audit", ); expect(producer).toContain("FROM scratch AS protected-mcporter-audit-evidence"); - expect(contents).toContain("FROM scratch AS protected-mcporter-audit-cache"); expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false", ); - expect(contents).toContain("from=protected-mcporter-audit-cache"); - expect(contents).toContain("bash /scripts/lib/verify-mcporter-audit.sh"); - expect(mcporterAuditHelper).toContain('secret_root="/run/secrets"'); - expect(mcporterAuditHelper).toContain( - 'seed_root="/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit"', - ); - expect(mcporterAuditHelper).not.toContain("NEMOCLAW_MCPORTER_AUDIT_SECRET_ROOT"); - expect(mcporterAuditHelper).not.toContain("NEMOCLAW_MCPORTER_AUDIT_SEED_ROOT"); - expect(mcporterAuditHelper).toContain( + expect(contents).toContain( "cached mcporter audit requires paired receipt, raw report, and receipt SHA-256", ); - expect(mcporterAuditHelper).toContain("seed-cached mcporter audit evidence is incomplete"); - expect(mcporterAuditHelper.replace(/\\\s*\n/g, " ").replace(/\s+/g, " ")).toContain( - `printf '%s %s\\n' "$receipt_sha256" "$receipt" | sha256sum --check --status -`, - ); }); it("copies the cached base-image audit report only after receipt verification succeeds", () => { From be13769dd1a62ce7b00cae7448b09a9417dabbf7 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 8 Sep 2026 13:42:43 +0700 Subject: [PATCH 08/56] fix: preserve audit evidence in protected cache Signed-off-by: San Dang --- Dockerfile | 26 +++---- .../checks/build-protected-managed-images.sh | 4 ++ .../materialize-locked-npm-cache-seed.mts | 70 ++++++++++++++++++- scripts/lib/verify-mcporter-audit.sh | 45 ++++++++++++ src/lib/sandbox/build-context.ts | 4 ++ .../materialize-locked-npm-cache-seed.test.ts | 43 +++++++++++- ...otected-managed-image-build-script.test.ts | 9 +++ .../sandbox/sandbox-build-context.test.ts | 4 ++ .../fetch-guard-patch-regression.test.ts | 4 ++ test/security/mcporter-supply-chain.test.ts | 24 +++++-- 10 files changed, 206 insertions(+), 27 deletions(-) create mode 100755 scripts/lib/verify-mcporter-audit.sh diff --git a/Dockerfile b/Dockerfile index cf22361afed..4044c8be2ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -546,6 +546,7 @@ COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts +COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -599,6 +600,11 @@ COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/ COPY --from=mcp-tool-discovery-runtime /opt/mcp-tool-discovery-runtime/dist/ /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime/ +# Protected GPU consumers receive reviewed audit evidence through the existing +# locked seed because their build driver comes from trusted main. +FROM scratch AS protected-mcporter-audit-cache +COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/ /seed/ + # Stage 3: Runtime image — pull cached base from GHCR # hadolint ignore=DL3006 FROM ${BASE_IMAGE} @@ -823,6 +829,7 @@ RUN command -v codex-acp >/dev/null RUN --network=default \ --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ + --mount=type=bind,from=protected-mcporter-audit-cache,source=/seed,target=/run/nemoclaw-mcporter-audit-cache \ set -eu; \ if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ @@ -986,24 +993,7 @@ RUN --network=default \ ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter; \ test "$(mcporter --version)" = "$MCPORTER_VERSION"; \ fi; \ - MCPORTER_RECEIPT=/run/secrets/nemoclaw-mcporter-audit-receipt; \ - MCPORTER_RAW_REPORT=/run/secrets/nemoclaw-mcporter-audit-raw-report; \ - if [ -f "$MCPORTER_RECEIPT" ] || [ -f "$MCPORTER_RAW_REPORT" ] || [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]; then \ - [ -f "$MCPORTER_RECEIPT" ] && [ -f "$MCPORTER_RAW_REPORT" ] && printf %s "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}' \ - || { echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2; exit 1; }; \ - printf '%s %s\n' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" "$MCPORTER_RECEIPT" | sha256sum -c -; \ -node --experimental-strip-types /scripts/lib/npm-audit-receipt.mts \ ---receipt "$MCPORTER_RECEIPT" \ ---package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ ---package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ ---raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ ---graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ ---registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true; \ - else \ - node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ - --directory /usr/local/lib/nemoclaw/mcporter-runtime \ - --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high; \ - fi + bash /scripts/lib/verify-mcporter-audit.sh # Patch OpenClaw media fetch for proxy-only sandbox (NVIDIA/NemoClaw#1755). # diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index be132b2ebe1..f42df3545a1 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -547,6 +547,10 @@ if [[ -n "$cache_to" ]]; then --os "$npm_target_os" \ --cpu "$npm_target_cpu" \ --libc "$npm_target_libc" + install -d -m 0700 "$cache_to/npm-cache-seed/reviewed-npm-audit" + install -m 0400 \ + "$audit_receipt" "$audit_raw_report" "$audit_evidence_dir/mcporter-runtime.receipt.sha256" \ + "$cache_to/npm-cache-seed/reviewed-npm-audit/" node --experimental-strip-types --no-warnings "$seed_helper" export \ --lockfile "$source_mcp_lockfile" \ --output "$cache_to/mcp-runtime-npm-cache-seed" \ diff --git a/scripts/checks/materialize-locked-npm-cache-seed.mts b/scripts/checks/materialize-locked-npm-cache-seed.mts index c446f44be70..3955ba4ea90 100644 --- a/scripts/checks/materialize-locked-npm-cache-seed.mts +++ b/scripts/checks/materialize-locked-npm-cache-seed.mts @@ -7,6 +7,7 @@ import { chmod, copyFile, lstat, + mkdir, mkdtemp, open, readdir, @@ -22,6 +23,12 @@ const MANIFEST_KIND = "nemoclaw-locked-npm-cache-seed-v1"; const MANIFEST_NAME = "manifest.json"; const REGISTRY_ORIGIN = "https://registry.npmjs.org"; const MAX_ARCHIVE_BYTES = 32 * 1024 * 1024; +const REVIEWED_AUDIT_DIRECTORY = "reviewed-npm-audit"; +const REVIEWED_AUDIT_FILES = [ + { maxBytes: 64 * 1024 * 1024, name: "mcporter-runtime.raw.json" }, + { maxBytes: 64 * 1024, name: "mcporter-runtime.receipt.json" }, + { maxBytes: 65, name: "mcporter-runtime.receipt.sha256" }, +] as const; const DOWNLOAD_CONCURRENCY = 6; const DOWNLOAD_ATTEMPTS = 4; const DOWNLOAD_TIMEOUT_MS = 30_000; @@ -93,6 +100,46 @@ function lockSha256(source: Uint8Array): string { return crypto.createHash("sha256").update(source).digest("hex"); } +async function reviewedAuditEvidence(seed: string): Promise { + const directory = path.join(seed, REVIEWED_AUDIT_DIRECTORY); + let status; + try { + status = await lstat(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + if (!status.isDirectory() || status.isSymbolicLink()) { + throw new Error("reviewed npm audit evidence must be one non-symlink directory"); + } + const entries = await readdir(directory, { withFileTypes: true }); + const expectedNames = REVIEWED_AUDIT_FILES.map(({ name }) => name).sort(); + if ( + entries.some((entry) => !entry.isFile() || entry.isSymbolicLink()) || + JSON.stringify(entries.map(({ name }) => name).sort()) !== JSON.stringify(expectedNames) + ) { + throw new Error("reviewed npm audit evidence contains missing or unexpected files"); + } + const evidence = await Promise.all( + REVIEWED_AUDIT_FILES.map(({ maxBytes, name }) => + exactFileSource(path.join(directory, name), `reviewed npm audit ${name}`, maxBytes), + ), + ); + const [rawReport, receipt, receiptHash] = evidence; + if ( + !rawReport?.length || + !receipt?.length || + receiptHash?.toString("utf8") !== `${lockSha256(receipt)}\n` + ) { + throw new Error("reviewed npm audit evidence failed receipt integrity validation"); + } + const parsedReceipt = record(JSON.parse(receipt.toString("utf8")), "reviewed npm audit receipt"); + if (parsedReceipt.rawResponseSha256 !== lockSha256(rawReport)) { + throw new Error("reviewed npm audit evidence failed raw-report integrity validation"); + } + return evidence; +} + function archiveIntegrity(source: Uint8Array): string { return `sha512-${crypto.createHash("sha512").update(source).digest("base64")}`; } @@ -251,7 +298,7 @@ export function lockedArchives( return [...byArchive.values()].sort((left, right) => left.archive.localeCompare(right.archive)); } -async function exactFileSource(file: string, label: string): Promise { +async function exactFileSource(file: string, label: string, maxBytes?: number): Promise { if (!path.isAbsolute(file) || file.includes("\n")) { throw new Error(`${label} must be an absolute path`); } @@ -260,7 +307,7 @@ async function exactFileSource(file: string, label: string): Promise { }); try { const status = await handle.stat(); - if (!status.isFile()) { + if (!status.isFile() || (maxBytes !== undefined && status.size > maxBytes)) { throw new Error(`${label} must be one regular non-symlink file`); } return await handle.readFile(); @@ -461,6 +508,7 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { const target = exactTarget(options.target); const expected = lockedArchives(lockSource.toString("utf8"), target); const seed = await exactDirectory(options.seed, "seed directory"); + const auditEvidence = await reviewedAuditEvidence(seed); const manifestSource = await exactFileSource(path.join(seed, MANIFEST_NAME), "seed manifest"); const manifest = parseManifest(manifestSource.toString("utf8")); if (manifest.lockSha256 !== lockSha256(lockSource)) { @@ -477,7 +525,11 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { throw new Error("npm cache seed manifest does not contain the complete locked archive set"); } const entries = await readdir(seed, { withFileTypes: true }); - const expectedNames = [...expected.map(({ archive }) => archive), MANIFEST_NAME].sort(); + const expectedNames = [ + ...expected.map(({ archive }) => archive), + MANIFEST_NAME, + ...(auditEvidence ? [REVIEWED_AUDIT_DIRECTORY] : []), + ].sort(); const actualNames = entries.map(({ name }) => name).sort(); if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) { throw new Error("npm cache seed directory contains missing or unexpected files"); @@ -502,6 +554,18 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { await copyFile(path.join(seed, archive.archive), destination); await chmod(destination, 0o444); } + if (auditEvidence) { + const auditDirectory = path.join(directory.temporary, REVIEWED_AUDIT_DIRECTORY); + await mkdir(auditDirectory, { mode: 0o700 }); + await Promise.all( + REVIEWED_AUDIT_FILES.map(({ name }, index) => + writeFile(path.join(auditDirectory, name), auditEvidence[index], { + flag: "wx", + mode: 0o400, + }), + ), + ); + } await directory.commit(); } catch (error) { await rm(directory.temporary, { force: true, recursive: true }); diff --git a/scripts/lib/verify-mcporter-audit.sh b/scripts/lib/verify-mcporter-audit.sh new file mode 100755 index 00000000000..7108db592cd --- /dev/null +++ b/scripts/lib/verify-mcporter-audit.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +receipt=/run/secrets/nemoclaw-mcporter-audit-receipt +raw_report=/run/secrets/nemoclaw-mcporter-audit-raw-report +receipt_sha256="${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" +seed=/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit + +if [[ -e "$receipt" || -L "$receipt" || -e "$raw_report" || -L "$raw_report" || -n "$receipt_sha256" ]]; then + [[ -f "$receipt" && ! -L "$receipt" && -f "$raw_report" && ! -L "$raw_report" && -n "$receipt_sha256" ]] || { + echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2 + exit 1 + } +elif [[ -e "$seed" || -L "$seed" ]]; then + receipt="$seed/mcporter-runtime.receipt.json" + raw_report="$seed/mcporter-runtime.raw.json" + receipt_hash="$seed/mcporter-runtime.receipt.sha256" + [[ -d "$seed" && ! -L "$seed" && -f "$receipt" && ! -L "$receipt" && -f "$raw_report" && ! -L "$raw_report" && -f "$receipt_hash" && ! -L "$receipt_hash" ]] || { + echo "ERROR: seed-cached mcporter audit evidence is incomplete" >&2 + exit 1 + } + read -r receipt_sha256 <"$receipt_hash" +else + exec node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ + --directory /usr/local/lib/nemoclaw/mcporter-runtime \ + --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high +fi + +printf '%s' "$receipt_sha256" | grep -qxE '[0-9a-f]{64}' || { + echo "ERROR: cached mcporter audit receipt SHA-256 is invalid" >&2 + exit 1 +} +printf '%s %s\n' "$receipt_sha256" "$receipt" | sha256sum --check --status - || { + echo "ERROR: cached mcporter audit receipt hash does not match" >&2 + exit 1 +} +exec node --experimental-strip-types /scripts/lib/npm-audit-receipt.mts \ + --receipt "$receipt" \ + --package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ + --raw-report "$raw_report" --exceptions /scripts/npm-audit-exceptions.json \ + --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ + --registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index e0e96f7a55b..41ff15a5ba1 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -461,6 +461,10 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "lib", "openclaw-npm-remediation.mts"), path.join(stagedScriptsDir, "lib", "openclaw-npm-remediation.mts"), ); + fs.copyFileSync( + path.join(rootDir, "scripts", "lib", "verify-mcporter-audit.sh"), + path.join(stagedScriptsDir, "lib", "verify-mcporter-audit.sh"), + ); normalizeReadModesForDockerCopy(stagedScriptsDir); return { buildCtx, stagedDockerfile }; diff --git a/test/install/materialize-locked-npm-cache-seed.test.ts b/test/install/materialize-locked-npm-cache-seed.test.ts index ee2bdd41f6d..82145073c3a 100644 --- a/test/install/materialize-locked-npm-cache-seed.test.ts +++ b/test/install/materialize-locked-npm-cache-seed.test.ts @@ -7,6 +7,7 @@ import { chmodSync, existsSync, mkdtempSync, + mkdirSync, readdirSync, readFileSync, rmSync, @@ -71,6 +72,21 @@ function writeLock(root: string, archives: readonly LockedArchive[]): string { return lockfile; } +function writeReviewedAuditEvidence(seed: string): void { + const directory = path.join(seed, "reviewed-npm-audit"); + const rawReport = Buffer.from('{"metadata":{"vulnerabilities":{}}}\n'); + const receipt = Buffer.from( + `${JSON.stringify({ rawResponseSha256: crypto.createHash("sha256").update(rawReport).digest("hex") })}\n`, + ); + mkdirSync(directory); + writeFileSync(path.join(directory, "mcporter-runtime.raw.json"), rawReport); + writeFileSync(path.join(directory, "mcporter-runtime.receipt.json"), receipt); + writeFileSync( + path.join(directory, "mcporter-runtime.receipt.sha256"), + `${crypto.createHash("sha256").update(receipt).digest("hex")}\n`, + ); +} + let testRoot = ""; beforeEach(() => { @@ -103,6 +119,7 @@ describe("locked npm cache seed materialization", () => { output: seed, target: TARGET, }); + writeReviewedAuditEvidence(seed); const verified = await verifyAndCopyLockedNpmCacheSeed({ lockfile, output: copied, @@ -116,10 +133,16 @@ describe("locked npm cache seed materialization", () => { "alpha-1.0.0.tgz", "beta-1.0.0.tgz", "manifest.json", + "reviewed-npm-audit", + ]); + expect(readdirSync(copied).sort()).toEqual([ + "alpha-1.0.0.tgz", + "beta-1.0.0.tgz", + "reviewed-npm-audit", ]); - expect(readdirSync(copied).sort()).toEqual(["alpha-1.0.0.tgz", "beta-1.0.0.tgz"]); expect(readFileSync(path.join(copied, alpha.locked.archive))).toEqual(alpha.bytes); expect(readFileSync(path.join(copied, beta.locked.archive))).toEqual(beta.bytes); + expect(readdirSync(path.join(copied, "reviewed-npm-audit"))).toHaveLength(3); }); it("materializes only the reachable archives for the selected npm platform", async () => { @@ -262,6 +285,24 @@ describe("locked npm cache seed materialization", () => { ).rejects.toThrow("npm cache seed directory contains missing or unexpected files"); }); + it("rejects an incomplete reviewed audit handoff", async () => { + const alpha = archive("alpha", "alpha archive"); + const lockfile = writeLock(testRoot, [alpha.locked]); + const seed = path.join(testRoot, "seed"); + await materializeLockedNpmCacheSeed({ + downloadArchive: async () => alpha.bytes, + lockfile, + output: seed, + target: TARGET, + }); + writeReviewedAuditEvidence(seed); + unlinkSync(path.join(seed, "reviewed-npm-audit", "mcporter-runtime.raw.json")); + + await expect( + verifyAndCopyLockedNpmCacheSeed({ lockfile, seed, target: TARGET }), + ).rejects.toThrow("reviewed npm audit evidence contains missing or unexpected files"); + }); + it.skipIf(process.platform === "win32")( "rejects a lock-pinned archive replaced with a symlink", async () => { diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index 23a2cfe8a30..448e900c44d 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -467,6 +467,15 @@ describe("protected managed-image build-cache boundary", () => { "utf8", ), ).toBe(`${DIGEST}\n`); + expect( + readFileSync( + path.join( + cacheRoot, + "npm-cache-seed/reviewed-npm-audit/mcporter-runtime.receipt.sha256", + ), + "utf8", + ), + ).toBe(`${DIGEST}\n`); expect(recordedBuildInvocation("openclaw")).toContain( `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(cacheRoot)}/reviewed-npm-audit/mcporter-runtime.receipt.json`, ); diff --git a/test/runtime/sandbox/sandbox-build-context.test.ts b/test/runtime/sandbox/sandbox-build-context.test.ts index b4dc2a1a1e5..3840b7f4b46 100644 --- a/test/runtime/sandbox/sandbox-build-context.test.ts +++ b/test/runtime/sandbox/sandbox-build-context.test.ts @@ -291,6 +291,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "lib", "reviewed-npm-audit.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "npm-audit-receipt.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "openclaw-npm-remediation.mts"), "fixture\n", 0o700); + writeFixture(path.join("scripts", "lib", "verify-mcporter-audit.sh"), "fixture\n", 0o700); fs.chmodSync(path.join(sourceRoot, "scripts"), 0o700); fs.chmodSync(path.join(sourceRoot, "scripts", "lib"), 0o700); } @@ -549,6 +550,9 @@ describe("sandbox build context staging", () => { ); expect((fs.statSync(stagedFile).mode & 0o777).toString(8)).toBe("644"); } + expect( + fs.readFileSync(path.join(buildCtx, "scripts/lib/verify-mcporter-audit.sh"), "utf8"), + ).toBe(fs.readFileSync(path.join(sourceRoot, "scripts/lib/verify-mcporter-audit.sh"), "utf8")); } it("normalizes restrictive and group-writable modes for Docker COPY", () => { diff --git a/test/security/fetch-guard-patch-regression.test.ts b/test/security/fetch-guard-patch-regression.test.ts index 403a5bf0a4b..edd203b698a 100644 --- a/test/security/fetch-guard-patch-regression.test.ts +++ b/test/security/fetch-guard-patch-regression.test.ts @@ -186,6 +186,10 @@ function runOpenClawUpgradeBlock(currentVersion: string) { "# OPENCLAW_VERSION is the NemoClaw runtime build target", "# Patch OpenClaw media fetch", ) + .replaceAll( + "bash /scripts/lib/verify-mcporter-audit.sh", + "node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", + ) .replaceAll("/opt/nemoclaw-blueprint/blueprint.yaml", blueprint) .replaceAll("/usr/local/lib/node_modules/openclaw", openclawInstall) .replaceAll( diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index 64a22fc3141..395a8c7f4ac 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -47,6 +47,10 @@ const reviewedAuditDriver = fs.readFileSync( path.join(repoRoot, "scripts", "audit-reviewed-npm-graph.mts"), "utf8", ); +const mcporterAuditHelper = fs.readFileSync( + path.join(repoRoot, "scripts", "lib", "verify-mcporter-audit.sh"), + "utf8", +); function extractIntegrityGate(contents: string): string { const startMarker = 'MCPORTER_EXPECTED_INTEGRITY=""'; const start = contents.indexOf(startMarker); @@ -188,9 +192,10 @@ describe("mcporter image supply-chain controls", () => { expect(unpinned.stdout).not.toContain("gate-passed"); }); - it.each(dockerfiles)("audits the committed dependency graph in $name", ({ contents }) => { - const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); - const auditReceiptInvocation = extractAuditReceiptInvocation(contents); + it.each(dockerfiles)("audits the committed dependency graph in $name", ({ name, contents }) => { + const auditContents = name === "Dockerfile" ? `${contents}\n${mcporterAuditHelper}` : contents; + const flattenedContents = auditContents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + const auditReceiptInvocation = extractAuditReceiptInvocation(auditContents); expect(contents).toContain( "COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/", ); @@ -223,7 +228,7 @@ describe("mcporter image supply-chain controls", () => { ); expect(expectedReviewedNpmVersion).toMatch(/^[0-9]+\.[0-9]+\.[0-9]+$/); expect(auditReceiptInvocation).not.toContain("--npm-version"); - expect(contents).not.toContain("--raw-copy"); + expect(auditContents).not.toContain("--raw-copy"); expect(auditReceiptInvocation).not.toMatch(/\bnpm\s+--version\b/); expect(auditReceiptInvocation).not.toMatch(/\$\(|`/); expect(contents).not.toContain(`${runtimePrefix} audit --omit=dev --audit-level=low`); @@ -246,12 +251,21 @@ describe("mcporter image supply-chain controls", () => { "NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH=mcporter-runtime NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR=artifacts/reviewed-npm-audit", ); expect(producer).toContain("FROM scratch AS protected-mcporter-audit-evidence"); + expect(contents).toContain("FROM scratch AS protected-mcporter-audit-cache"); expect(contents).toContain( - "--mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false", + "COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh", ); expect(contents).toContain( + "--mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false", + ); + expect(contents).toContain("from=protected-mcporter-audit-cache"); + expect(mcporterAuditHelper).toContain( + "seed=/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", + ); + expect(mcporterAuditHelper).toContain( "cached mcporter audit requires paired receipt, raw report, and receipt SHA-256", ); + expect(mcporterAuditHelper).toContain("seed-cached mcporter audit evidence is incomplete"); }); it("copies the cached base-image audit report only after receipt verification succeeds", () => { From 38ee4352a869effa5fa0fefcf4223a095ffe7b8c Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 8 Sep 2026 14:55:25 +0700 Subject: [PATCH 09/56] test: stage protected audit verifier in fixtures --- test/agents/openclaw/openclaw-integrity-pin-suite.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/agents/openclaw/openclaw-integrity-pin-suite.ts b/test/agents/openclaw/openclaw-integrity-pin-suite.ts index f6e217dd6c9..9ccf589a62b 100644 --- a/test/agents/openclaw/openclaw-integrity-pin-suite.ts +++ b/test/agents/openclaw/openclaw-integrity-pin-suite.ts @@ -464,6 +464,10 @@ function runInstallBlock( .replaceAll("/usr/local/bin", path.join(tmp, "usr-local-bin")) .replaceAll("/scripts/lib/reviewed-npm-archive.mts", REVIEWED_NPM_ARCHIVE_HELPER) .replaceAll("/scripts/lib/openclaw-npm-remediation.mts", remediationHelper) + .replaceAll( + "bash /scripts/lib/verify-mcporter-audit.sh", + `node --experimental-strip-types ${auditHelper} --directory ${mcporterRuntime} --exceptions ${auditExceptionFile} --graph mcporter-runtime --threshold high`, + ) .replaceAll("/scripts/lib/reviewed-npm-audit.mts", auditHelper) .replaceAll("/scripts/npm-audit-exceptions.json", auditExceptionFile), ].join("\n"); @@ -694,7 +698,9 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(reviewNote).toContain( "The long-term source of truth for these behaviors remains upstream OpenClaw", ); - expect(reviewNote).toContain("test/agents/openclaw/openclaw-real-patched-dist-harness.test.ts"); + expect(reviewNote).toContain( + "test/agents/openclaw/openclaw-real-patched-dist-harness.test.ts", + ); expect(reviewNote).toContain("NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS=1"); expect(reviewNote).toContain("not a substitute for focused nightly E2E proof"); expect(reviewNote).toContain("OpenClaw Diagnostics OTEL Host Gateway Boundary"); From cd3679e0d376177bd08cd0130e84cecb76f88d61 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:46:57 -0700 Subject: [PATCH 10/56] fix(security): preserve protected audit isolation Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- Dockerfile | 6 +- scripts/lib/reviewed-npm-audit.mts | 29 ++++++--- .../releases/reviewed-npm-audit.test.ts | 59 ++++++++++--------- test/security/mcporter-supply-chain.test.ts | 6 ++ 4 files changed, 61 insertions(+), 39 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4044c8be2ff..d6d95860ce1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -600,8 +600,8 @@ COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/ COPY --from=mcp-tool-discovery-runtime /opt/mcp-tool-discovery-runtime/dist/ /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime/ -# Protected GPU consumers receive reviewed audit evidence through the existing -# locked seed because their build driver comes from trusted main. +# Protected qualification supplies reviewed audit evidence as BuildKit secrets. +# A trusted driver without secret wiring can use the locked-seed fallback. FROM scratch AS protected-mcporter-audit-cache COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/ /seed/ @@ -826,7 +826,7 @@ RUN command -v codex-acp >/dev/null # OPENCLAW_VERSION is the NemoClaw runtime build target and must meet the blueprint minimum. # Reviewed archives retain registry and packed-byte SRI, basename, local-only install, and cleanup gates. # hadolint ignore=DL3059,DL4006,DL3016,SC2015 -RUN --network=default \ +RUN \ --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ --mount=type=bind,from=protected-mcporter-audit-cache,source=/seed,target=/run/nemoclaw-mcporter-audit-cache \ diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index 793178f6fd8..c393876dc57 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -152,7 +152,8 @@ export type NpmAuditResponseClassification = | Readonly<{ failure: NpmAuditFailureClassification }> | Readonly<{ report: Record }>; -const RETRYABLE_TRANSPORT_CODE = "ECONNRESET"; +const RETRYABLE_TRANSPORT_CODES = ["EAI_AGAIN", "ECONNRESET"] as const; +type RetryableTransportCode = (typeof RETRYABLE_TRANSPORT_CODES)[number]; function asRecord(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -332,14 +333,24 @@ function firstInvalidAuditField(value: unknown): string | undefined { return undefined; } -function hasRetryableTransportError(report: Record, stderr: string): boolean { +function retryableTransportCode( + report: Record, + stderr: string, +): RetryableTransportCode | undefined { const error = typeof report.error === "object" && report.error !== null && !Array.isArray(report.error) ? (report.error as Record) : {}; - return [report.message, error.code, error.summary, error.detail, stderr] - .filter((value): value is string => typeof value === "string") - .some((value) => /(?:^|[^A-Z0-9_])ECONNRESET(?:$|[^A-Z0-9_])/u.test(value)); + const values = [report.message, error.code, error.summary, error.detail, stderr].filter( + (value): value is string => typeof value === "string", + ); + return RETRYABLE_TRANSPORT_CODES.find((code) => + values.some((value) => + code === "EAI_AGAIN" + ? /(?:^|[^A-Z0-9_])EAI_AGAIN(?:$|[^A-Z0-9_])/u.test(value) + : /(?:^|[^A-Z0-9_])ECONNRESET(?:$|[^A-Z0-9_])/u.test(value), + ), + ); } function rejectedAuditResponse( @@ -387,10 +398,11 @@ export function classifyNpmAuditResponse(result: { const report = value as Record; const invalidField = firstInvalidAuditField(report); if (report.error !== undefined) { - const retryable = hasRetryableTransportError(report, result.stderr); + const transport = retryableTransportCode(report, result.stderr); + const retryable = transport !== undefined; const reason = retryable ? "registry-network-error" : "npm-error-document"; return rejectedAuditResponse(result, reason, retryable, [ - ...(retryable ? [`transport=${RETRYABLE_TRANSPORT_CODE}`] : []), + ...(transport ? [`transport=${transport}`] : []), ...(invalidField ? [`required-field=${invalidField}`] : []), ]); } @@ -999,8 +1011,7 @@ export function runReviewedNpmAudit( const audit = cached ? runNpmAuditWithRetry({ run: () => cached.result, wait: () => {}, warn: () => {} }) : runNpmAuditWithRetry({ - run: () => - spawnSync("npm", NPM_AUDIT_ARGV, npmAuditProcessOptions(options.directory)), + run: () => spawnSync("npm", NPM_AUDIT_ARGV, npmAuditProcessOptions(options.directory)), }); const finishedAt = new Date().toISOString(); if (!cached && cacheFile && cacheInput && audit.report) diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index 896869b39f2..41c65951eb0 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -187,31 +187,36 @@ describe("reviewed npm audit gate", () => { ).toEqual({ report }); }); - it("classifies npm 11.18.0's observed registry error document without exposing its message (#11088)", () => { - const secret = "https://audit-user:registry-secret@registry.example/private"; - const classified = classifyNpmAuditResponse({ - status: 1, - stderr: `authorization: Bearer stderr-secret for ${secret}`, - stdout: JSON.stringify({ - message: `request to ${secret} failed, reason: read ECONNRESET`, - error: { summary: "", detail: "" }, - }), - }); + it.each(["EAI_AGAIN", "ECONNRESET"])( + "classifies the %s registry error without exposing its message (#11088)", + (transport) => { + const secret = "https://audit-user:registry-secret@registry.example/private"; + const classified = classifyNpmAuditResponse({ + status: 1, + stderr: `authorization: Bearer stderr-secret for ${secret}`, + stdout: JSON.stringify({ + message: `request to ${secret} failed, reason: ${transport}`, + error: { summary: "", detail: "" }, + }), + }); - expect(classified).toEqual({ - failure: { - diagnostic: expect.stringMatching( - /^exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=ECONNRESET required-field=metadata:missing$/, - ), - reason: "registry-network-error", - retryable: true, - }, - }); - expect(JSON.stringify(classified)).not.toContain("audit-user"); - expect(JSON.stringify(classified)).not.toContain("registry-secret"); - expect(JSON.stringify(classified)).not.toContain("stderr-secret"); - expect(JSON.stringify(classified)).not.toContain("registry.example"); - }); + expect(classified).toEqual({ + failure: { + diagnostic: expect.stringMatching( + new RegExp( + `^exit=1 stdout-bytes=\\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=${transport} required-field=metadata:missing$`, + ), + ), + reason: "registry-network-error", + retryable: true, + }, + }); + expect(JSON.stringify(classified)).not.toContain("audit-user"); + expect(JSON.stringify(classified)).not.toContain("registry-secret"); + expect(JSON.stringify(classified)).not.toContain("stderr-secret"); + expect(JSON.stringify(classified)).not.toContain("registry.example"); + }, + ); it.each([ ["missing metadata", {}], @@ -238,7 +243,7 @@ describe("reviewed npm audit gate", () => { }); }); - it("retries only the observed registry reset with bounded backoff", () => { + it("retries the observed registry lookup failure with bounded backoff", () => { const completeReport = { metadata: { vulnerabilities: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, @@ -250,7 +255,7 @@ describe("reviewed npm audit gate", () => { { status: 1, stderr: sensitiveStderr, - stdout: JSON.stringify({ message: "read ECONNRESET", error: { summary: "" } }), + stdout: JSON.stringify({ message: "getaddrinfo EAI_AGAIN", error: { summary: "" } }), }, { status: 0, stderr: "", stdout: JSON.stringify(completeReport) }, ]; @@ -268,7 +273,7 @@ describe("reviewed npm audit gate", () => { expect(delays).toEqual([1_000]); expect(warnings).toEqual([ expect.stringMatching( - /^npm audit scan failed on attempt 1\/2; retrying in 1000 ms \(reason=registry-network-error; exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=ECONNRESET required-field=metadata:missing\)$/, + /^npm audit scan failed on attempt 1\/2; retrying in 1000 ms \(reason=registry-network-error; exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=EAI_AGAIN required-field=metadata:missing\)$/, ), ]); const warningOutput = warnings.join("\n"); diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index 395a8c7f4ac..83f634ef872 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -243,6 +243,9 @@ describe("mcporter image supply-chain controls", () => { const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); const producer = fs.readFileSync(path.join(repoRoot, "Dockerfile.protected-npm-audit"), "utf8"); const flattenedProducer = producer.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + const installStart = contents.indexOf("# Upgrade stale bases."); + const installEnd = contents.indexOf("# Patch OpenClaw media fetch", installStart); + const protectedInstall = contents.slice(installStart, installEnd); expect(producer).toContain( `FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c AS protected-mcporter-audit`, @@ -266,6 +269,9 @@ describe("mcporter image supply-chain controls", () => { "cached mcporter audit requires paired receipt, raw report, and receipt SHA-256", ); expect(mcporterAuditHelper).toContain("seed-cached mcporter audit evidence is incomplete"); + expect(installStart).toBeGreaterThanOrEqual(0); + expect(installEnd).toBeGreaterThan(installStart); + expect(protectedInstall).not.toMatch(/RUN --network=(?:default|host)/); }); it("copies the cached base-image audit report only after receipt verification succeeds", () => { From 08bb2e2b550e0946e22d1330c9523fd639264f4d Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:27:05 -0700 Subject: [PATCH 11/56] fix(images): preserve audit RUN parsing Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index d6d95860ce1..a26563b9f0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -826,8 +826,7 @@ RUN command -v codex-acp >/dev/null # OPENCLAW_VERSION is the NemoClaw runtime build target and must meet the blueprint minimum. # Reviewed archives retain registry and packed-byte SRI, basename, local-only install, and cleanup gates. # hadolint ignore=DL3059,DL4006,DL3016,SC2015 -RUN \ - --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ +RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ --mount=type=bind,from=protected-mcporter-audit-cache,source=/seed,target=/run/nemoclaw-mcporter-audit-cache \ set -eu; \ From 253f247ccb941f91d003b590c05ea5cabbbcb2d8 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:36:05 -0700 Subject: [PATCH 12/56] fix(images): clean failed protected cache exports Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../checks/build-protected-managed-images.sh | 57 ++++++++++--------- ...otected-managed-image-build-script.test.ts | 16 ++++++ 2 files changed, 47 insertions(+), 26 deletions(-) diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index f42df3545a1..a1af8ba5c62 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -167,6 +167,36 @@ for command in curl docker jq node sha256sum; do } done +work_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/nemoclaw-protected-images.XXXXXX")" +cache_export_complete=0 +seed_overlay_active=0 +seed_backup="$work_dir/npm-cache-seed-original" +mcp_seed_overlay_active=0 +mcp_seed_backup="$work_dir/mcp-runtime-npm-cache-seed-original" +messaging_seed_overlay_active=0 +messaging_seed_backup="$work_dir/messaging-npm-cache-seed-original" +restore_worktree() { + if [[ "$seed_overlay_active" == 1 ]]; then + rm -rf -- "$source_seed_dir" + cp -pR -- "$seed_backup" "$source_seed_dir" + fi + if [[ "$mcp_seed_overlay_active" == 1 ]]; then + rm -rf -- "$source_mcp_seed_dir" + cp -pR -- "$mcp_seed_backup" "$source_mcp_seed_dir" + fi + if [[ "$messaging_seed_overlay_active" == 1 ]]; then + rm -rf -- "$source_messaging_seed_dir" + cp -pR -- "$messaging_seed_backup" "$source_messaging_seed_dir" + fi + if [[ -n "$cache_to" && "$cache_export_complete" != 1 && -d "$cache_to" ]]; then + find "$cache_to" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + fi + rm -rf -- "$work_dir" +} +trap restore_worktree EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + audit_evidence_dir="" audit_receipt="" audit_raw_report="" @@ -218,32 +248,6 @@ if [[ -n "$audit_evidence_dir" ]]; then validate_audit_evidence "$audit_evidence_dir" fi -work_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/nemoclaw-protected-images.XXXXXX")" -seed_overlay_active=0 -seed_backup="$work_dir/npm-cache-seed-original" -mcp_seed_overlay_active=0 -mcp_seed_backup="$work_dir/mcp-runtime-npm-cache-seed-original" -messaging_seed_overlay_active=0 -messaging_seed_backup="$work_dir/messaging-npm-cache-seed-original" -restore_worktree() { - if [[ "$seed_overlay_active" == 1 ]]; then - rm -rf -- "$source_seed_dir" - cp -pR -- "$seed_backup" "$source_seed_dir" - fi - if [[ "$mcp_seed_overlay_active" == 1 ]]; then - rm -rf -- "$source_mcp_seed_dir" - cp -pR -- "$mcp_seed_backup" "$source_mcp_seed_dir" - fi - if [[ "$messaging_seed_overlay_active" == 1 ]]; then - rm -rf -- "$source_messaging_seed_dir" - cp -pR -- "$messaging_seed_backup" "$source_messaging_seed_dir" - fi - rm -rf -- "$work_dir" -} -trap restore_worktree EXIT -trap 'exit 130' INT -trap 'exit 143' TERM - if [[ -n "$cache_from" ]]; then imported_seed="$work_dir/npm-cache-seed-import" node --experimental-strip-types --no-warnings "$seed_helper" copy \ @@ -579,3 +583,4 @@ jq -se \ end ' "$contracts" >"${output}.tmp" mv "${output}.tmp" "$output" +cache_export_complete=1 diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index 448e900c44d..1b054c54784 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -491,6 +491,22 @@ describe("protected managed-image build-cache boundary", () => { ); }); + it("cleans an incomplete cache export after a protected build fails", () => { + const cacheRoot = path.join(testRoot, "export-cache"); + stubBuildInvocation(); + dockerBuildFailureMode = "near-match"; + + const failed = runBuild(REPO_ROOT, ["--cache-to", cacheRoot]); + + expect(failed.status, failed.stderr).toBe(42); + expect(readdirSync(cacheRoot)).toEqual([]); + + dockerBuildFailureMode = ""; + const retried = runBuild(REPO_ROOT, ["--cache-to", cacheRoot]); + + expect(retried.status, retried.stderr).toBe(0); + }); + it.each([ ["relative", () => "export-cache"], [ From 50739325bbef1f379ea67baca01840894e32b61c Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:34:07 -0700 Subject: [PATCH 13/56] test(security): verify protected audit handoff Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../reviewed-npm-audit-handoff.test.ts | 100 ++++++++++++------ 1 file changed, 69 insertions(+), 31 deletions(-) diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 8b29d29cba4..360d77436c8 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -90,33 +90,32 @@ describe("reviewed npm audit handoff", () => { }, ); - it("passes producer output to the Docker receipt verifier and rejects an npm mismatch", () => { + it("passes producer output through the protected audit helper and rejects a forged report", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")); const packageJsonFile = path.join(root, "package.json"); const packageLockFile = path.join(root, "package-lock.json"); const rawReportFile = path.join(root, "report.json"); - const exceptionFile = path.join(root, "exceptions.json"); - const auditConfigFile = path.join(root, "reviewed-npm-audit.json"); - const resultFile = path.join(root, "policy.json"); - const packageJson = Buffer.from("temporary manifest\n"); - const packageLock = Buffer.from("temporary lock\n"); - const exceptionPolicy = '{"schemaVersion":1,"exceptions":[]}\n'; + const runtime = path.join(REPO_ROOT, "agents/openclaw/mcporter-runtime"); + const exceptionFile = path.join(REPO_ROOT, "ci/npm-audit-exceptions.json"); + const auditConfigFile = path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"); + const packageJson = fs.readFileSync(path.join(runtime, "package.json")); + const packageLock = fs.readFileSync(path.join(runtime, "package-lock.json")); + const exceptionPolicy = fs.readFileSync(exceptionFile, "utf8"); + const npmVersion = JSON.parse(fs.readFileSync(auditConfigFile, "utf8")).npmVersion as string; const rawReport = '{"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n'; try { fs.writeFileSync(packageJsonFile, packageJson); fs.writeFileSync(packageLockFile, packageLock); fs.writeFileSync(rawReportFile, rawReport); - fs.writeFileSync(exceptionFile, exceptionPolicy); - fs.writeFileSync(auditConfigFile, JSON.stringify({ npmVersion: "10.9.4" })); fs.writeFileSync( path.join(root, "report.provenance.json"), JSON.stringify({ run: { startedAt: new Date().toISOString() } }), ); const receiptFile = emitAuditReceipt({ artifactDirectory: root, - graphId: "temporary-graph", - npmVersion: "10.9.4", + graphId: "mcporter-runtime", + npmVersion, packageJsonFile, packageLockFile, preserveInputs: true, @@ -126,7 +125,7 @@ describe("reviewed npm audit handoff", () => { acceptedAdvisories: [], blockingThreshold: "high", exceptionPolicySha256: createHash("sha256").update(exceptionPolicy).digest("hex"), - graph: "temporary-graph", + graph: "mcporter-runtime", reported: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, schemaVersion: 1, status: "clean", @@ -135,12 +134,13 @@ describe("reviewed npm audit handoff", () => { threshold: "high", }); - const retainedPackageJson = path.join(root, "temporary-graph.package.json"); - const retainedPackageLock = path.join(root, "temporary-graph.package-lock.json"); - const transportRawReport = path.join(root, "temporary-graph.raw.json"); + const retainedPackageJson = path.join(root, "mcporter-runtime.package.json"); + const retainedPackageLock = path.join(root, "mcporter-runtime.package-lock.json"); + const transportRawReport = path.join(root, "mcporter-runtime.raw.json"); + const receiptVerifier = path.join(REPO_ROOT, "scripts", "lib", "npm-audit-receipt.mts"); const verifierArgs = [ "--experimental-strip-types", - path.join(REPO_ROOT, "scripts", "lib", "npm-audit-receipt.mts"), + receiptVerifier, "--receipt", receiptFile, "--package-json", @@ -152,32 +152,70 @@ describe("reviewed npm audit handoff", () => { "--exceptions", exceptionFile, "--graph", - "temporary-graph", + "mcporter-runtime", "--audit-config", auditConfigFile, "--registry", "https://registry.yarnpkg.com", "--threshold", "high", - "--result", - resultFile, + "--legacy-npmjs", + "true", ]; - const accepted = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); + const nodeLog = path.join(root, "node.log"); + const stubBin = path.join(root, "bin"); + const helper = path.join(root, "verify-mcporter-audit.sh"); + fs.mkdirSync(stubBin); + fs.writeFileSync( + path.join(stubBin, "node"), + '#!/usr/bin/env bash\nset -euo pipefail\nprintf \'%s\\n\' "$*" >>"$NEMOCLAW_TEST_NODE_LOG"\nexec "$NEMOCLAW_TEST_REAL_NODE" "$@"\n', + { mode: 0o755 }, + ); + let helperSource = fs.readFileSync( + path.join(REPO_ROOT, "scripts/lib/verify-mcporter-audit.sh"), + "utf8", + ); + for (const [source, staged] of [ + ["/run/secrets/nemoclaw-mcporter-audit-receipt", receiptFile], + ["/run/secrets/nemoclaw-mcporter-audit-raw-report", transportRawReport], + ["/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", path.join(root, "no-seed")], + ["/scripts/lib/npm-audit-receipt.mts", receiptVerifier], + ["/usr/local/lib/nemoclaw/mcporter-runtime/package.json", retainedPackageJson], + ["/usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json", retainedPackageLock], + ["/scripts/npm-audit-exceptions.json", exceptionFile], + ["/scripts/reviewed-npm-audit.json", auditConfigFile], + ] as const) { + helperSource = helperSource.replaceAll(source, staged); + } + fs.writeFileSync(helper, helperSource, { mode: 0o755 }); + const runHelper = () => + spawnSync("bash", [helper], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: createHash("sha256") + .update(fs.readFileSync(receiptFile)) + .digest("hex"), + NEMOCLAW_TEST_NODE_LOG: nodeLog, + NEMOCLAW_TEST_REAL_NODE: process.execPath, + PATH: `${stubBin}:${process.env.PATH ?? ""}`, + }, + }); + + fs.writeFileSync(transportRawReport, "{}\n"); + const rejected = runHelper(); + expect(rejected.status).not.toBe(0); + expect(rejected.stderr).toContain("receipt rawResponseSha256 does not match"); + + fs.writeFileSync(transportRawReport, rawReport); + const accepted = runHelper(); expect(accepted.status, accepted.stderr).toBe(0); expect(fs.readFileSync(retainedPackageJson)).toEqual(packageJson); expect(fs.readFileSync(retainedPackageLock)).toEqual(packageLock); expect(fs.readFileSync(transportRawReport, "utf8")).toBe(rawReport); - expect(JSON.parse(fs.readFileSync(resultFile, "utf8"))).toMatchObject({ - graph: "temporary-graph", - status: "clean", - }); - - fs.rmSync(resultFile); - fs.writeFileSync(auditConfigFile, JSON.stringify({ npmVersion: "11.18.0" })); - const rejected = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); - expect(rejected.status).not.toBe(0); - expect(rejected.stderr).toContain("receipt identity does not match expected graph and npm"); - expect(fs.existsSync(resultFile)).toBe(false); + expect(fs.readFileSync(nodeLog, "utf8").trim().split("\n").at(-1)).toBe( + verifierArgs.join(" "), + ); } finally { fs.rmSync(root, { recursive: true, force: true }); } From bdf27c87fe8d4c351bb61d60497e111714f86bc2 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:38:10 -0700 Subject: [PATCH 14/56] test(security): keep audit handoff setup direct Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../reviewed-npm-audit-handoff.test.ts | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 360d77436c8..fa4ffc3e872 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -175,18 +175,21 @@ describe("reviewed npm audit handoff", () => { path.join(REPO_ROOT, "scripts/lib/verify-mcporter-audit.sh"), "utf8", ); - for (const [source, staged] of [ - ["/run/secrets/nemoclaw-mcporter-audit-receipt", receiptFile], - ["/run/secrets/nemoclaw-mcporter-audit-raw-report", transportRawReport], - ["/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", path.join(root, "no-seed")], - ["/scripts/lib/npm-audit-receipt.mts", receiptVerifier], - ["/usr/local/lib/nemoclaw/mcporter-runtime/package.json", retainedPackageJson], - ["/usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json", retainedPackageLock], - ["/scripts/npm-audit-exceptions.json", exceptionFile], - ["/scripts/reviewed-npm-audit.json", auditConfigFile], - ] as const) { - helperSource = helperSource.replaceAll(source, staged); - } + helperSource = helperSource + .replaceAll("/run/secrets/nemoclaw-mcporter-audit-receipt", receiptFile) + .replaceAll("/run/secrets/nemoclaw-mcporter-audit-raw-report", transportRawReport) + .replaceAll( + "/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", + path.join(root, "no-seed"), + ) + .replaceAll("/scripts/lib/npm-audit-receipt.mts", receiptVerifier) + .replaceAll("/usr/local/lib/nemoclaw/mcporter-runtime/package.json", retainedPackageJson) + .replaceAll( + "/usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json", + retainedPackageLock, + ) + .replaceAll("/scripts/npm-audit-exceptions.json", exceptionFile) + .replaceAll("/scripts/reviewed-npm-audit.json", auditConfigFile); fs.writeFileSync(helper, helperSource, { mode: 0o755 }); const runHelper = () => spawnSync("bash", [helper], { From e7b9017be37ea08df2819bcad748f49e23991790 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:58:18 -0700 Subject: [PATCH 15/56] test(security): rely on audit verifier bindings Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/automation/releases/reviewed-npm-audit-handoff.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index fa4ffc3e872..937e1ac109d 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -213,8 +213,6 @@ describe("reviewed npm audit handoff", () => { fs.writeFileSync(transportRawReport, rawReport); const accepted = runHelper(); expect(accepted.status, accepted.stderr).toBe(0); - expect(fs.readFileSync(retainedPackageJson)).toEqual(packageJson); - expect(fs.readFileSync(retainedPackageLock)).toEqual(packageLock); expect(fs.readFileSync(transportRawReport, "utf8")).toBe(rawReport); expect(fs.readFileSync(nodeLog, "utf8").trim().split("\n").at(-1)).toBe( verifierArgs.join(" "), From ff73c9755a3dee78d2a3578275d3bb6493784176 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:29:54 -0700 Subject: [PATCH 16/56] fix(images): retry reviewed audit export Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../checks/build-protected-managed-images.sh | 52 ++++++++++--------- ...otected-managed-image-build-script.test.ts | 37 +++++++++++++ 2 files changed, 65 insertions(+), 24 deletions(-) diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index a1af8ba5c62..2963141c63e 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -223,28 +223,8 @@ validate_audit_evidence() { } } -if [[ -n "$cache_to" ]]; then - audit_evidence_dir="$cache_to/reviewed-npm-audit" - docker buildx build \ - --file "$source_root/Dockerfile.protected-npm-audit" \ - --platform "$platform" \ - --target protected-mcporter-audit-evidence \ - --output "type=local,dest=${audit_evidence_dir}" \ - --provenance=false \ - --sbom=false \ - "$source_root" - [[ -f "$audit_evidence_dir/mcporter-runtime.receipt.json" && ! -L "$audit_evidence_dir/mcporter-runtime.receipt.json" ]] || { - echo "ERROR: protected managed-image reviewed audit receipt is missing or unsafe" >&2 - exit 1 - } - sha256sum "$audit_evidence_dir/mcporter-runtime.receipt.json" | awk '{print $1}' \ - >"$audit_evidence_dir/mcporter-runtime.receipt.sha256" - chmod 0400 "$audit_evidence_dir/mcporter-runtime.receipt.sha256" -elif [[ -n "$cache_from" ]]; then +if [[ -n "$cache_from" ]]; then audit_evidence_dir="$cache_from/reviewed-npm-audit" -fi - -if [[ -n "$audit_evidence_dir" ]]; then validate_audit_evidence "$audit_evidence_dir" fi @@ -333,7 +313,8 @@ confirm_build_retry_state() { run_build_with_retry() { local agent="$1" local image_repository="$2" - shift 2 + local retry_cleanup="$3" + shift 3 local -a build_command=("$@") local attempt_log="$work_dir/${agent}-build-attempt.log" local max_attempts=2 @@ -381,7 +362,9 @@ run_build_with_retry() { return "$build_status" fi - if ! confirm_build_retry_state "$agent" "$image_repository"; then + if [[ -n "$retry_cleanup" ]]; then + rm -rf -- "$retry_cleanup" + elif ! confirm_build_retry_state "$agent" "$image_repository"; then echo "::error::Protected managed-image build outcome=failed-no-retry agent=${agent} attempt=${attempt}/${max_attempts} failure=state-check" >&2 return "$build_status" fi @@ -467,7 +450,7 @@ build_agent() { --build-arg "NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root" --build-arg "TARGETARCH=${target_arch}" "$source_root") - run_build_with_retry "$agent" "$image_repository" "${build_command[@]}" + run_build_with_retry "$agent" "$image_repository" "" "${build_command[@]}" local digest digest="$(jq -er '."containerimage.digest"' "$metadata")" @@ -531,6 +514,27 @@ build_agent() { }' >>"$contracts" } +if [[ -n "$cache_to" ]]; then + audit_evidence_dir="$cache_to/reviewed-npm-audit" + audit_build_command=(docker buildx build + --file "$source_root/Dockerfile.protected-npm-audit" + --platform "$platform" + --target protected-mcporter-audit-evidence + --output "type=local,dest=${audit_evidence_dir}" + --provenance=false + --sbom=false + "$source_root") + run_build_with_retry "reviewed-npm-audit" "" "$audit_evidence_dir" "${audit_build_command[@]}" + [[ -f "$audit_evidence_dir/mcporter-runtime.receipt.json" && ! -L "$audit_evidence_dir/mcporter-runtime.receipt.json" ]] || { + echo "ERROR: protected managed-image reviewed audit receipt is missing or unsafe" >&2 + exit 1 + } + sha256sum "$audit_evidence_dir/mcporter-runtime.receipt.json" | awk '{print $1}' \ + >"$audit_evidence_dir/mcporter-runtime.receipt.sha256" + chmod 0400 "$audit_evidence_dir/mcporter-runtime.receipt.sha256" + validate_audit_evidence "$audit_evidence_dir" +fi + build_agent \ openclaw \ Dockerfile \ diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index 1b054c54784..e84934b29c5 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -28,6 +28,8 @@ const DIGEST = "b".repeat(64); let testRoot = ""; let stubBin = ""; let dockerLog = ""; +let dockerAuditBuildCount = ""; +let dockerAuditBuildFailureMode = ""; let dockerBuildCount = ""; let dockerBuildFailureMode = ""; let seedLog = ""; @@ -61,6 +63,18 @@ case "$*" in destination="\${output_spec#type=local,dest=}" [[ -n "$destination" && "$destination" != "$output_spec" ]] mkdir -p "$destination" + audit_build_count=0 + if [[ -f "$NEMOCLAW_TEST_DOCKER_AUDIT_BUILD_COUNT" ]]; then + read -r audit_build_count <"$NEMOCLAW_TEST_DOCKER_AUDIT_BUILD_COUNT" + fi + audit_build_count=$((audit_build_count + 1)) + printf '%s\n' "$audit_build_count" >"$NEMOCLAW_TEST_DOCKER_AUDIT_BUILD_COUNT" + if [[ "$NEMOCLAW_TEST_DOCKER_AUDIT_BUILD_FAILURE_MODE:$audit_build_count" == "exact-once:1" ]]; then + printf 'partial\n' >"$destination/partial" + printf '%s\n' 'ERROR: failed to build: failed to solve: stream error: stream ID 71; INTERNAL_ERROR; received from peer' >&2 + exit 42 + fi + [[ ! -e "$destination/partial" ]] printf '{"result":"pass"}\n' >"$destination/mcporter-runtime.receipt.json" printf '{"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n' >"$destination/mcporter-runtime.raw.json" exit 0 @@ -283,6 +297,8 @@ function runBuild( encoding: "utf8", env: { ...process.env, + NEMOCLAW_TEST_DOCKER_AUDIT_BUILD_COUNT: dockerAuditBuildCount, + NEMOCLAW_TEST_DOCKER_AUDIT_BUILD_FAILURE_MODE: dockerAuditBuildFailureMode, NEMOCLAW_TEST_DOCKER_BUILD_COUNT: dockerBuildCount, NEMOCLAW_TEST_DOCKER_BUILD_FAILURE_MODE: dockerBuildFailureMode, NEMOCLAW_TEST_DOCKER_LOG: dockerLog, @@ -303,6 +319,8 @@ beforeEach(() => { testRoot = mkdtempSync(path.join(os.tmpdir(), "nemoclaw-protected-build-")); stubBin = path.join(testRoot, "bin"); dockerLog = path.join(testRoot, "docker.log"); + dockerAuditBuildCount = path.join(testRoot, "docker-audit-build-count"); + dockerAuditBuildFailureMode = ""; dockerBuildCount = path.join(testRoot, "docker-build-count"); dockerBuildFailureMode = ""; seedLog = path.join(testRoot, "seed.log"); @@ -507,6 +525,25 @@ describe("protected managed-image build-cache boundary", () => { expect(retried.status, retried.stderr).toBe(0); }); + it("retries a transient reviewed audit build from clean evidence", () => { + const cacheRoot = path.join(testRoot, "export-cache"); + stubBuildInvocation(); + dockerAuditBuildFailureMode = "exact-once"; + + const result = runBuild(REPO_ROOT, ["--cache-to", cacheRoot]); + const output = `${result.stdout}${result.stderr}`; + + expect(result.status, output).toBe(0); + expect(recordedAuditBuildInvocations()).toHaveLength(2); + expect(existsSync(path.join(cacheRoot, "reviewed-npm-audit", "partial"))).toBe(false); + expect(output).toContain( + "outcome=transient-external agent=reviewed-npm-audit attempt=1/2 retry-in=2s failure=buildkit-http2-internal-error", + ); + expect(output).toContain( + "outcome=passed-after-retry agent=reviewed-npm-audit attempt=2/2", + ); + }); + it.each([ ["relative", () => "export-cache"], [ From b9b31a06e35f69bf26d9e8cf5ce1b0ee0077e661 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:57:45 -0700 Subject: [PATCH 17/56] fix(security): reject build-context audit evidence Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- Dockerfile | 6 -- agents/openclaw/dependency-review.md | 2 +- .../checks/build-protected-managed-images.sh | 4 -- .../materialize-locked-npm-cache-seed.mts | 70 +------------------ scripts/lib/verify-mcporter-audit.sh | 10 +-- .../reviewed-npm-audit-handoff.test.ts | 26 ++++++- .../materialize-locked-npm-cache-seed.test.ts | 43 +----------- ...otected-managed-image-build-script.test.ts | 31 +++----- test/security/mcporter-supply-chain.test.ts | 5 +- 9 files changed, 45 insertions(+), 152 deletions(-) diff --git a/Dockerfile b/Dockerfile index a26563b9f0e..f7f0d84cb3d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -600,11 +600,6 @@ COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/ COPY --from=mcp-tool-discovery-runtime /opt/mcp-tool-discovery-runtime/dist/ /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime/ -# Protected qualification supplies reviewed audit evidence as BuildKit secrets. -# A trusted driver without secret wiring can use the locked-seed fallback. -FROM scratch AS protected-mcporter-audit-cache -COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/ /seed/ - # Stage 3: Runtime image — pull cached base from GHCR # hadolint ignore=DL3006 FROM ${BASE_IMAGE} @@ -828,7 +823,6 @@ RUN command -v codex-acp >/dev/null # hadolint ignore=DL3059,DL4006,DL3016,SC2015 RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ - --mount=type=bind,from=protected-mcporter-audit-cache,source=/seed,target=/run/nemoclaw-mcporter-audit-cache \ set -eu; \ if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index 6afef43b8dd..633ca925c4d 100644 --- a/agents/openclaw/dependency-review.md +++ b/agents/openclaw/dependency-review.md @@ -28,7 +28,7 @@ Update it and `agents/openclaw/mcporter-runtime/package*.json` together whenever Both image paths install the committed graph with `npm ci --ignore-scripts --omit=dev` because the published package declares no install-time lifecycle script and NemoClaw needs only its already-built CLI. The reviewed audit wrapper reports lower-severity production findings and blocks unaccepted high or critical advisories. The default `ci/npm-audit-exceptions.json` registry is empty. Any future exception must match one advisory, graph, package, installed version, and severity; identify an owner and NemoClaw tracking issue; state a decision, rationale, and expiry no more than 30 days away; and include compensating controls for temporary risk acceptance. Missing, malformed, expired, overlong, mismatched, or unused exceptions fail closed. The repository-wide audit also rejects exceptions for unknown graph IDs. Registry signature verification remains a separate control. -Managed image publication supplies the mcporter receipt and raw report as BuildKit secrets. Protected qualification produces the same evidence during its networked cache export and carries it alongside that cache into the offline rebuild. A build without either evidence source runs the reviewed audit directly and fails closed if completeness cannot be established. +Managed image publication supplies the mcporter receipt and raw report as BuildKit secrets. Protected qualification produces the same evidence during its networked cache export and supplies it to the offline rebuild through the same secret boundary. A build without that evidence runs the reviewed audit directly and fails closed if completeness cannot be established. ## WeChat plugin runtime graph diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index 2963141c63e..d455e362121 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -555,10 +555,6 @@ if [[ -n "$cache_to" ]]; then --os "$npm_target_os" \ --cpu "$npm_target_cpu" \ --libc "$npm_target_libc" - install -d -m 0700 "$cache_to/npm-cache-seed/reviewed-npm-audit" - install -m 0400 \ - "$audit_receipt" "$audit_raw_report" "$audit_evidence_dir/mcporter-runtime.receipt.sha256" \ - "$cache_to/npm-cache-seed/reviewed-npm-audit/" node --experimental-strip-types --no-warnings "$seed_helper" export \ --lockfile "$source_mcp_lockfile" \ --output "$cache_to/mcp-runtime-npm-cache-seed" \ diff --git a/scripts/checks/materialize-locked-npm-cache-seed.mts b/scripts/checks/materialize-locked-npm-cache-seed.mts index 3955ba4ea90..c446f44be70 100644 --- a/scripts/checks/materialize-locked-npm-cache-seed.mts +++ b/scripts/checks/materialize-locked-npm-cache-seed.mts @@ -7,7 +7,6 @@ import { chmod, copyFile, lstat, - mkdir, mkdtemp, open, readdir, @@ -23,12 +22,6 @@ const MANIFEST_KIND = "nemoclaw-locked-npm-cache-seed-v1"; const MANIFEST_NAME = "manifest.json"; const REGISTRY_ORIGIN = "https://registry.npmjs.org"; const MAX_ARCHIVE_BYTES = 32 * 1024 * 1024; -const REVIEWED_AUDIT_DIRECTORY = "reviewed-npm-audit"; -const REVIEWED_AUDIT_FILES = [ - { maxBytes: 64 * 1024 * 1024, name: "mcporter-runtime.raw.json" }, - { maxBytes: 64 * 1024, name: "mcporter-runtime.receipt.json" }, - { maxBytes: 65, name: "mcporter-runtime.receipt.sha256" }, -] as const; const DOWNLOAD_CONCURRENCY = 6; const DOWNLOAD_ATTEMPTS = 4; const DOWNLOAD_TIMEOUT_MS = 30_000; @@ -100,46 +93,6 @@ function lockSha256(source: Uint8Array): string { return crypto.createHash("sha256").update(source).digest("hex"); } -async function reviewedAuditEvidence(seed: string): Promise { - const directory = path.join(seed, REVIEWED_AUDIT_DIRECTORY); - let status; - try { - status = await lstat(directory); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; - throw error; - } - if (!status.isDirectory() || status.isSymbolicLink()) { - throw new Error("reviewed npm audit evidence must be one non-symlink directory"); - } - const entries = await readdir(directory, { withFileTypes: true }); - const expectedNames = REVIEWED_AUDIT_FILES.map(({ name }) => name).sort(); - if ( - entries.some((entry) => !entry.isFile() || entry.isSymbolicLink()) || - JSON.stringify(entries.map(({ name }) => name).sort()) !== JSON.stringify(expectedNames) - ) { - throw new Error("reviewed npm audit evidence contains missing or unexpected files"); - } - const evidence = await Promise.all( - REVIEWED_AUDIT_FILES.map(({ maxBytes, name }) => - exactFileSource(path.join(directory, name), `reviewed npm audit ${name}`, maxBytes), - ), - ); - const [rawReport, receipt, receiptHash] = evidence; - if ( - !rawReport?.length || - !receipt?.length || - receiptHash?.toString("utf8") !== `${lockSha256(receipt)}\n` - ) { - throw new Error("reviewed npm audit evidence failed receipt integrity validation"); - } - const parsedReceipt = record(JSON.parse(receipt.toString("utf8")), "reviewed npm audit receipt"); - if (parsedReceipt.rawResponseSha256 !== lockSha256(rawReport)) { - throw new Error("reviewed npm audit evidence failed raw-report integrity validation"); - } - return evidence; -} - function archiveIntegrity(source: Uint8Array): string { return `sha512-${crypto.createHash("sha512").update(source).digest("base64")}`; } @@ -298,7 +251,7 @@ export function lockedArchives( return [...byArchive.values()].sort((left, right) => left.archive.localeCompare(right.archive)); } -async function exactFileSource(file: string, label: string, maxBytes?: number): Promise { +async function exactFileSource(file: string, label: string): Promise { if (!path.isAbsolute(file) || file.includes("\n")) { throw new Error(`${label} must be an absolute path`); } @@ -307,7 +260,7 @@ async function exactFileSource(file: string, label: string, maxBytes?: number): }); try { const status = await handle.stat(); - if (!status.isFile() || (maxBytes !== undefined && status.size > maxBytes)) { + if (!status.isFile()) { throw new Error(`${label} must be one regular non-symlink file`); } return await handle.readFile(); @@ -508,7 +461,6 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { const target = exactTarget(options.target); const expected = lockedArchives(lockSource.toString("utf8"), target); const seed = await exactDirectory(options.seed, "seed directory"); - const auditEvidence = await reviewedAuditEvidence(seed); const manifestSource = await exactFileSource(path.join(seed, MANIFEST_NAME), "seed manifest"); const manifest = parseManifest(manifestSource.toString("utf8")); if (manifest.lockSha256 !== lockSha256(lockSource)) { @@ -525,11 +477,7 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { throw new Error("npm cache seed manifest does not contain the complete locked archive set"); } const entries = await readdir(seed, { withFileTypes: true }); - const expectedNames = [ - ...expected.map(({ archive }) => archive), - MANIFEST_NAME, - ...(auditEvidence ? [REVIEWED_AUDIT_DIRECTORY] : []), - ].sort(); + const expectedNames = [...expected.map(({ archive }) => archive), MANIFEST_NAME].sort(); const actualNames = entries.map(({ name }) => name).sort(); if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) { throw new Error("npm cache seed directory contains missing or unexpected files"); @@ -554,18 +502,6 @@ export async function verifyAndCopyLockedNpmCacheSeed(options: { await copyFile(path.join(seed, archive.archive), destination); await chmod(destination, 0o444); } - if (auditEvidence) { - const auditDirectory = path.join(directory.temporary, REVIEWED_AUDIT_DIRECTORY); - await mkdir(auditDirectory, { mode: 0o700 }); - await Promise.all( - REVIEWED_AUDIT_FILES.map(({ name }, index) => - writeFile(path.join(auditDirectory, name), auditEvidence[index], { - flag: "wx", - mode: 0o400, - }), - ), - ); - } await directory.commit(); } catch (error) { await rm(directory.temporary, { force: true, recursive: true }); diff --git a/scripts/lib/verify-mcporter-audit.sh b/scripts/lib/verify-mcporter-audit.sh index 7108db592cd..41c9377bcb5 100755 --- a/scripts/lib/verify-mcporter-audit.sh +++ b/scripts/lib/verify-mcporter-audit.sh @@ -15,14 +15,8 @@ if [[ -e "$receipt" || -L "$receipt" || -e "$raw_report" || -L "$raw_report" || exit 1 } elif [[ -e "$seed" || -L "$seed" ]]; then - receipt="$seed/mcporter-runtime.receipt.json" - raw_report="$seed/mcporter-runtime.raw.json" - receipt_hash="$seed/mcporter-runtime.receipt.sha256" - [[ -d "$seed" && ! -L "$seed" && -f "$receipt" && ! -L "$receipt" && -f "$raw_report" && ! -L "$raw_report" && -f "$receipt_hash" && ! -L "$receipt_hash" ]] || { - echo "ERROR: seed-cached mcporter audit evidence is incomplete" >&2 - exit 1 - } - read -r receipt_sha256 <"$receipt_hash" + echo "ERROR: build-context mcporter audit evidence is not trusted" >&2 + exit 1 else exec node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ --directory /usr/local/lib/nemoclaw/mcporter-runtime \ diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 937e1ac109d..2f595618dd3 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -90,7 +90,7 @@ describe("reviewed npm audit handoff", () => { }, ); - it("passes producer output through the protected audit helper and rejects a forged report", () => { + it("passes producer output through protected audit handoffs and rejects forged reports", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")); const packageJsonFile = path.join(root, "package.json"); const packageLockFile = path.join(root, "package-lock.json"); @@ -217,6 +217,30 @@ describe("reviewed npm audit handoff", () => { expect(fs.readFileSync(nodeLog, "utf8").trim().split("\n").at(-1)).toBe( verifierArgs.join(" "), ); + + const seedEvidence = path.join(root, "seed", "reviewed-npm-audit"); + const seedHelper = path.join(root, "verify-mcporter-seed-audit.sh"); + fs.mkdirSync(seedEvidence, { recursive: true }); + fs.copyFileSync(receiptFile, path.join(seedEvidence, "mcporter-runtime.receipt.json")); + fs.writeFileSync(path.join(seedEvidence, "mcporter-runtime.raw.json"), rawReport); + fs.writeFileSync( + path.join(seedEvidence, "mcporter-runtime.receipt.sha256"), + `${createHash("sha256").update(fs.readFileSync(receiptFile)).digest("hex")}\n`, + ); + fs.writeFileSync( + seedHelper, + helperSource + .replaceAll(receiptFile, path.join(root, "missing-secret-receipt")) + .replaceAll(transportRawReport, path.join(root, "missing-secret-report")) + .replaceAll(path.join(root, "no-seed"), seedEvidence), + { mode: 0o755 }, + ); + const rejectedSeed = spawnSync("bash", [seedHelper], { + encoding: "utf8", + env: { ...process.env, NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: "" }, + }); + expect(rejectedSeed.status).not.toBe(0); + expect(rejectedSeed.stderr).toContain("build-context mcporter audit evidence is not trusted"); } finally { fs.rmSync(root, { recursive: true, force: true }); } diff --git a/test/install/materialize-locked-npm-cache-seed.test.ts b/test/install/materialize-locked-npm-cache-seed.test.ts index 82145073c3a..ee2bdd41f6d 100644 --- a/test/install/materialize-locked-npm-cache-seed.test.ts +++ b/test/install/materialize-locked-npm-cache-seed.test.ts @@ -7,7 +7,6 @@ import { chmodSync, existsSync, mkdtempSync, - mkdirSync, readdirSync, readFileSync, rmSync, @@ -72,21 +71,6 @@ function writeLock(root: string, archives: readonly LockedArchive[]): string { return lockfile; } -function writeReviewedAuditEvidence(seed: string): void { - const directory = path.join(seed, "reviewed-npm-audit"); - const rawReport = Buffer.from('{"metadata":{"vulnerabilities":{}}}\n'); - const receipt = Buffer.from( - `${JSON.stringify({ rawResponseSha256: crypto.createHash("sha256").update(rawReport).digest("hex") })}\n`, - ); - mkdirSync(directory); - writeFileSync(path.join(directory, "mcporter-runtime.raw.json"), rawReport); - writeFileSync(path.join(directory, "mcporter-runtime.receipt.json"), receipt); - writeFileSync( - path.join(directory, "mcporter-runtime.receipt.sha256"), - `${crypto.createHash("sha256").update(receipt).digest("hex")}\n`, - ); -} - let testRoot = ""; beforeEach(() => { @@ -119,7 +103,6 @@ describe("locked npm cache seed materialization", () => { output: seed, target: TARGET, }); - writeReviewedAuditEvidence(seed); const verified = await verifyAndCopyLockedNpmCacheSeed({ lockfile, output: copied, @@ -133,16 +116,10 @@ describe("locked npm cache seed materialization", () => { "alpha-1.0.0.tgz", "beta-1.0.0.tgz", "manifest.json", - "reviewed-npm-audit", - ]); - expect(readdirSync(copied).sort()).toEqual([ - "alpha-1.0.0.tgz", - "beta-1.0.0.tgz", - "reviewed-npm-audit", ]); + expect(readdirSync(copied).sort()).toEqual(["alpha-1.0.0.tgz", "beta-1.0.0.tgz"]); expect(readFileSync(path.join(copied, alpha.locked.archive))).toEqual(alpha.bytes); expect(readFileSync(path.join(copied, beta.locked.archive))).toEqual(beta.bytes); - expect(readdirSync(path.join(copied, "reviewed-npm-audit"))).toHaveLength(3); }); it("materializes only the reachable archives for the selected npm platform", async () => { @@ -285,24 +262,6 @@ describe("locked npm cache seed materialization", () => { ).rejects.toThrow("npm cache seed directory contains missing or unexpected files"); }); - it("rejects an incomplete reviewed audit handoff", async () => { - const alpha = archive("alpha", "alpha archive"); - const lockfile = writeLock(testRoot, [alpha.locked]); - const seed = path.join(testRoot, "seed"); - await materializeLockedNpmCacheSeed({ - downloadArchive: async () => alpha.bytes, - lockfile, - output: seed, - target: TARGET, - }); - writeReviewedAuditEvidence(seed); - unlinkSync(path.join(seed, "reviewed-npm-audit", "mcporter-runtime.raw.json")); - - await expect( - verifyAndCopyLockedNpmCacheSeed({ lockfile, seed, target: TARGET }), - ).rejects.toThrow("reviewed npm audit evidence contains missing or unexpected files"); - }); - it.skipIf(process.platform === "win32")( "rejects a lock-pinned archive replaced with a symlink", async () => { diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index e84934b29c5..b6102d69fd9 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -264,11 +264,7 @@ function recordedBuildInvocation(agent: string): string { return invocation!; } -function runBuild( - sourceRoot: string, - extraArgs: readonly string[] = [], - platform = "linux/amd64", -) { +function runBuild(sourceRoot: string, extraArgs: readonly string[] = [], platform = "linux/amd64") { const output = path.join(testRoot, "contracts.json"); return spawnSync( "bash", @@ -385,7 +381,9 @@ describe("protected managed-image build-cache boundary", () => { expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--platform linux/arm64"); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( + "--platform linux/arm64", + ); expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( "--build-arg TARGETARCH=arm64", ); @@ -424,8 +422,12 @@ describe("protected managed-image build-cache boundary", () => { expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--build-arg TARGETARCH=arm64"); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( + "--platform linux/arm64", + ); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( + "--build-arg TARGETARCH=arm64", + ); }); it("passes each agent one empty absolute cache export root", () => { @@ -485,15 +487,6 @@ describe("protected managed-image build-cache boundary", () => { "utf8", ), ).toBe(`${DIGEST}\n`); - expect( - readFileSync( - path.join( - cacheRoot, - "npm-cache-seed/reviewed-npm-audit/mcporter-runtime.receipt.sha256", - ), - "utf8", - ), - ).toBe(`${DIGEST}\n`); expect(recordedBuildInvocation("openclaw")).toContain( `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(cacheRoot)}/reviewed-npm-audit/mcporter-runtime.receipt.json`, ); @@ -539,9 +532,7 @@ describe("protected managed-image build-cache boundary", () => { expect(output).toContain( "outcome=transient-external agent=reviewed-npm-audit attempt=1/2 retry-in=2s failure=buildkit-http2-internal-error", ); - expect(output).toContain( - "outcome=passed-after-retry agent=reviewed-npm-audit attempt=2/2", - ); + expect(output).toContain("outcome=passed-after-retry agent=reviewed-npm-audit attempt=2/2"); }); it.each([ diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index 83f634ef872..c2170c316fe 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -254,21 +254,20 @@ describe("mcporter image supply-chain controls", () => { "NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH=mcporter-runtime NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR=artifacts/reviewed-npm-audit", ); expect(producer).toContain("FROM scratch AS protected-mcporter-audit-evidence"); - expect(contents).toContain("FROM scratch AS protected-mcporter-audit-cache"); expect(contents).toContain( "COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh", ); expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false", ); - expect(contents).toContain("from=protected-mcporter-audit-cache"); + expect(contents).not.toContain("from=protected-mcporter-audit-cache"); expect(mcporterAuditHelper).toContain( "seed=/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", ); expect(mcporterAuditHelper).toContain( "cached mcporter audit requires paired receipt, raw report, and receipt SHA-256", ); - expect(mcporterAuditHelper).toContain("seed-cached mcporter audit evidence is incomplete"); + expect(mcporterAuditHelper).toContain("build-context mcporter audit evidence is not trusted"); expect(installStart).toBeGreaterThanOrEqual(0); expect(installEnd).toBeGreaterThan(installStart); expect(protectedInstall).not.toMatch(/RUN --network=(?:default|host)/); From a5a7bd586b739052a82fb07a1e06ad60ddb9a6a3 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:09:40 -0700 Subject: [PATCH 18/56] test(images): detect changed audit receipt --- ...otected-managed-image-build-script.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index b6102d69fd9..40d07544ee2 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -644,6 +644,32 @@ describe("protected managed-image build-cache boundary", () => { }, ); + it("rejects a changed imported audit receipt before invoking Docker (#11088)", () => { + const cacheRoot = path.join(testRoot, "imported-cache"); + completeImportedCache(cacheRoot); + stubBuildInvocation(); + writeExecutable( + "sha256sum", + `#!/usr/bin/env bash +if [[ "$(<"$1")" == '{"result":"pass"}' ]]; then + printf '%s %s\\n' '${DIGEST}' "$1" +else + printf '%s %s\\n' '${"c".repeat(64)}' "$1" +fi +`, + ); + writeFileSync( + path.join(cacheRoot, "reviewed-npm-audit", "mcporter-runtime.receipt.json"), + '{"result":"changed"}\n', + ); + + const result = runBuild(REPO_ROOT, ["--cache-from", cacheRoot]); + + expect(result.status, result.stderr).toBe(1); + expect(result.stderr).toContain("reviewed audit receipt hash does not match"); + expect(existsSync(dockerLog)).toBe(false); + }); + it("imports locked seeds, reuses safe agent caches, and disables RUN network access", () => { const cacheRoot = path.join(testRoot, "imported-cache"); const sourceSeed = path.join(REPO_ROOT, "tools/mcp-tool-discovery-runtime/npm-cache-seed"); From 5d2782c18fdcdd7c1a1fb654bcb75c9ecc0c3700 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:37:11 -0700 Subject: [PATCH 19/56] fix(audit): retry refused registry connections --- scripts/lib/reviewed-npm-audit.mts | 8 ++------ test/automation/releases/reviewed-npm-audit.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index c393876dc57..3280b2603d0 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -152,7 +152,7 @@ export type NpmAuditResponseClassification = | Readonly<{ failure: NpmAuditFailureClassification }> | Readonly<{ report: Record }>; -const RETRYABLE_TRANSPORT_CODES = ["EAI_AGAIN", "ECONNRESET"] as const; +const RETRYABLE_TRANSPORT_CODES = ["EAI_AGAIN", "ECONNRESET", "ECONNREFUSED"] as const; type RetryableTransportCode = (typeof RETRYABLE_TRANSPORT_CODES)[number]; function asRecord(value: unknown, label: string): Record { @@ -345,11 +345,7 @@ function retryableTransportCode( (value): value is string => typeof value === "string", ); return RETRYABLE_TRANSPORT_CODES.find((code) => - values.some((value) => - code === "EAI_AGAIN" - ? /(?:^|[^A-Z0-9_])EAI_AGAIN(?:$|[^A-Z0-9_])/u.test(value) - : /(?:^|[^A-Z0-9_])ECONNRESET(?:$|[^A-Z0-9_])/u.test(value), - ), + values.some((value) => new RegExp(`(?:^|[^A-Z0-9_])${code}(?:$|[^A-Z0-9_])`, "u").test(value)), ); } diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index 41c65951eb0..9c5394b5db4 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -187,7 +187,7 @@ describe("reviewed npm audit gate", () => { ).toEqual({ report }); }); - it.each(["EAI_AGAIN", "ECONNRESET"])( + it.each(["EAI_AGAIN", "ECONNRESET", "ECONNREFUSED"])( "classifies the %s registry error without exposing its message (#11088)", (transport) => { const secret = "https://audit-user:registry-secret@registry.example/private"; @@ -255,7 +255,7 @@ describe("reviewed npm audit gate", () => { { status: 1, stderr: sensitiveStderr, - stdout: JSON.stringify({ message: "getaddrinfo EAI_AGAIN", error: { summary: "" } }), + stdout: JSON.stringify({ message: "connect ECONNREFUSED", error: { summary: "" } }), }, { status: 0, stderr: "", stdout: JSON.stringify(completeReport) }, ]; @@ -273,7 +273,7 @@ describe("reviewed npm audit gate", () => { expect(delays).toEqual([1_000]); expect(warnings).toEqual([ expect.stringMatching( - /^npm audit scan failed on attempt 1\/2; retrying in 1000 ms \(reason=registry-network-error; exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=EAI_AGAIN required-field=metadata:missing\)$/, + /^npm audit scan failed on attempt 1\/2; retrying in 1000 ms \(reason=registry-network-error; exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=ECONNREFUSED required-field=metadata:missing\)$/, ), ]); const warningOutput = warnings.join("\n"); From 451ca9bca8978feb3babc3a35d9e5b1b72407396 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:20:50 -0700 Subject: [PATCH 20/56] refactor(audit): share mcporter evidence verification --- Dockerfile.base | 26 ++----------- scripts/lib/verify-mcporter-audit.sh | 21 ++++++++-- .../reviewed-npm-audit-handoff.test.ts | 39 ++++++++++++++++++- test/security/mcporter-supply-chain.test.ts | 9 +++-- 4 files changed, 65 insertions(+), 30 deletions(-) diff --git a/Dockerfile.base b/Dockerfile.base index 71b6c683295..a47bf1b0732 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -429,6 +429,7 @@ COPY scripts/lib/reviewed-npm-archive.mts \ scripts/lib/openclaw-npm-remediation.mts \ /scripts/lib/ COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts +COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -580,28 +581,9 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep 'const { StreamableHTTPServerTransport } = await import("file:///usr/local/lib/nemoclaw/mcporter-runtime/node_modules/@modelcontextprotocol/sdk/dist/esm/server/streamableHttp.js"); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await transport.close();' \ && ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter \ && test "$(mcporter --version)" = "$MCPORTER_VERSION" \ - && MCPORTER_RECEIPT=/run/secrets/nemoclaw-mcporter-audit-receipt \ - && MCPORTER_RAW_REPORT=/run/secrets/nemoclaw-mcporter-audit-raw-report \ - && if [ -f "$MCPORTER_RECEIPT" ] || [ -f "$MCPORTER_RAW_REPORT" ] || [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]; then \ - [ -f "$MCPORTER_RECEIPT" ] && [ -f "$MCPORTER_RAW_REPORT" ] && printf %s "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}' \ - || { echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2; exit 1; }; \ - printf '%s %s\n' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" "$MCPORTER_RECEIPT" | sha256sum -c -; \ - node --experimental-strip-types /scripts/lib/npm-audit-receipt.mts \ - --receipt "$MCPORTER_RECEIPT" \ - --package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ - --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ - --raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ - --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ - --registry https://registry.yarnpkg.com --threshold high \ - --legacy-npmjs true \ - --result /tmp/mcporter-npm-audit-policy.json \ - && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json; \ - else \ - node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ - --directory /usr/local/lib/nemoclaw/mcporter-runtime \ - --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high \ - --report /tmp/mcporter-npm-audit.json --result /tmp/mcporter-npm-audit-policy.json; \ - fi \ + && NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json \ + NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json \ + bash /scripts/lib/verify-mcporter-audit.sh \ && MCPORTER_AUDIT_STATUS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').status")" \ && MCPORTER_AUDIT_EXCEPTIONS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').acceptedAdvisories.join(',') || 'none'")" \ && MCPORTER_AUDIT_POLICY_SHA256="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').exceptionPolicySha256")" \ diff --git a/scripts/lib/verify-mcporter-audit.sh b/scripts/lib/verify-mcporter-audit.sh index 41c9377bcb5..41b514b5e2a 100755 --- a/scripts/lib/verify-mcporter-audit.sh +++ b/scripts/lib/verify-mcporter-audit.sh @@ -8,6 +8,15 @@ receipt=/run/secrets/nemoclaw-mcporter-audit-receipt raw_report=/run/secrets/nemoclaw-mcporter-audit-raw-report receipt_sha256="${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" seed=/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit +report_path="${NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH:-}" +result_path="${NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH:-}" +audit_output_args=() +receipt_output_args=() +[[ -z "$report_path" ]] || audit_output_args+=(--report "$report_path") +if [[ -n "$result_path" ]]; then + audit_output_args+=(--result "$result_path") + receipt_output_args+=(--result "$result_path") +fi if [[ -e "$receipt" || -L "$receipt" || -e "$raw_report" || -L "$raw_report" || -n "$receipt_sha256" ]]; then [[ -f "$receipt" && ! -L "$receipt" && -f "$raw_report" && ! -L "$raw_report" && -n "$receipt_sha256" ]] || { @@ -18,9 +27,11 @@ elif [[ -e "$seed" || -L "$seed" ]]; then echo "ERROR: build-context mcporter audit evidence is not trusted" >&2 exit 1 else - exec node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ + node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ --directory /usr/local/lib/nemoclaw/mcporter-runtime \ - --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high + --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high \ + "${audit_output_args[@]}" + exit fi printf '%s' "$receipt_sha256" | grep -qxE '[0-9a-f]{64}' || { @@ -31,9 +42,11 @@ printf '%s %s\n' "$receipt_sha256" "$receipt" | sha256sum --check --status - || echo "ERROR: cached mcporter audit receipt hash does not match" >&2 exit 1 } -exec node --experimental-strip-types /scripts/lib/npm-audit-receipt.mts \ +node --experimental-strip-types /scripts/lib/npm-audit-receipt.mts \ --receipt "$receipt" \ --package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ --raw-report "$raw_report" --exceptions /scripts/npm-audit-exceptions.json \ --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ - --registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true + --registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true \ + "${receipt_output_args[@]}" +[[ -z "$report_path" ]] || cp -- "$raw_report" "$report_path" diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 2f595618dd3..7782cc35072 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -138,6 +138,8 @@ describe("reviewed npm audit handoff", () => { const retainedPackageLock = path.join(root, "mcporter-runtime.package-lock.json"); const transportRawReport = path.join(root, "mcporter-runtime.raw.json"); const receiptVerifier = path.join(REPO_ROOT, "scripts", "lib", "npm-audit-receipt.mts"); + const retainedReport = path.join(root, "retained-report.json"); + const retainedResult = path.join(root, "retained-result.json"); const verifierArgs = [ "--experimental-strip-types", receiptVerifier, @@ -161,6 +163,8 @@ describe("reviewed npm audit handoff", () => { "high", "--legacy-npmjs", "true", + "--result", + retainedResult, ]; const nodeLog = path.join(root, "node.log"); const stubBin = path.join(root, "bin"); @@ -168,7 +172,7 @@ describe("reviewed npm audit handoff", () => { fs.mkdirSync(stubBin); fs.writeFileSync( path.join(stubBin, "node"), - '#!/usr/bin/env bash\nset -euo pipefail\nprintf \'%s\\n\' "$*" >>"$NEMOCLAW_TEST_NODE_LOG"\nexec "$NEMOCLAW_TEST_REAL_NODE" "$@"\n', + '#!/usr/bin/env bash\nset -euo pipefail\nprintf \'%s\\n\' "$*" >>"$NEMOCLAW_TEST_NODE_LOG"\n[[ "$*" != *"/scripts/lib/reviewed-npm-audit.mts"* ]] || exit 0\nexec "$NEMOCLAW_TEST_REAL_NODE" "$@"\n', { mode: 0o755 }, ); let helperSource = fs.readFileSync( @@ -199,6 +203,8 @@ describe("reviewed npm audit handoff", () => { NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: createHash("sha256") .update(fs.readFileSync(receiptFile)) .digest("hex"), + NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, + NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, NEMOCLAW_TEST_NODE_LOG: nodeLog, NEMOCLAW_TEST_REAL_NODE: process.execPath, PATH: `${stubBin}:${process.env.PATH ?? ""}`, @@ -209,15 +215,46 @@ describe("reviewed npm audit handoff", () => { const rejected = runHelper(); expect(rejected.status).not.toBe(0); expect(rejected.stderr).toContain("receipt rawResponseSha256 does not match"); + expect(fs.existsSync(retainedReport)).toBe(false); + expect(fs.existsSync(retainedResult)).toBe(false); fs.writeFileSync(transportRawReport, rawReport); const accepted = runHelper(); expect(accepted.status, accepted.stderr).toBe(0); expect(fs.readFileSync(transportRawReport, "utf8")).toBe(rawReport); + expect(fs.readFileSync(retainedReport, "utf8")).toBe(rawReport); + expect(JSON.parse(fs.readFileSync(retainedResult, "utf8"))).toMatchObject({ + graph: "mcporter-runtime", + status: "clean", + }); expect(fs.readFileSync(nodeLog, "utf8").trim().split("\n").at(-1)).toBe( verifierArgs.join(" "), ); + const directHelper = path.join(root, "verify-mcporter-direct-audit.sh"); + fs.writeFileSync( + directHelper, + helperSource + .replaceAll(receiptFile, path.join(root, "missing-direct-receipt")) + .replaceAll(transportRawReport, path.join(root, "missing-direct-report")), + { mode: 0o755 }, + ); + const direct = spawnSync("bash", [directHelper], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: "", + NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, + NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, + NEMOCLAW_TEST_NODE_LOG: nodeLog, + PATH: `${stubBin}:${process.env.PATH ?? ""}`, + }, + }); + expect(direct.status, direct.stderr).toBe(0); + expect(fs.readFileSync(nodeLog, "utf8").trim().split("\n").at(-1)).toContain( + `--report ${retainedReport} --result ${retainedResult}`, + ); + const seedEvidence = path.join(root, "seed", "reviewed-npm-audit"); const seedHelper = path.join(root, "verify-mcporter-seed-audit.sh"); fs.mkdirSync(seedEvidence, { recursive: true }); diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index c2170c316fe..c5fc66b396d 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -192,8 +192,8 @@ describe("mcporter image supply-chain controls", () => { expect(unpinned.stdout).not.toContain("gate-passed"); }); - it.each(dockerfiles)("audits the committed dependency graph in $name", ({ name, contents }) => { - const auditContents = name === "Dockerfile" ? `${contents}\n${mcporterAuditHelper}` : contents; + it.each(dockerfiles)("audits the committed dependency graph in $name", ({ contents }) => { + const auditContents = `${contents}\n${mcporterAuditHelper}`; const flattenedContents = auditContents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); const auditReceiptInvocation = extractAuditReceiptInvocation(auditContents); expect(contents).toContain( @@ -277,8 +277,11 @@ describe("mcporter image supply-chain controls", () => { const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile.base"), "utf8"); const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + expect(contents).toContain( + "COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh", + ); expect(flattenedContents).toContain( - '--result /tmp/mcporter-npm-audit-policy.json && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json;', + "NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json bash /scripts/lib/verify-mcporter-audit.sh", ); }); From e433dfc32aeae63c5acc9522e23978b1bdf5cfc1 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:35:24 -0700 Subject: [PATCH 21/56] test(images): retain base audit outputs --- test/agents/openclaw/openclaw-integrity-pin-suite.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/agents/openclaw/openclaw-integrity-pin-suite.ts b/test/agents/openclaw/openclaw-integrity-pin-suite.ts index 1e31a8c3908..d9fe142953a 100644 --- a/test/agents/openclaw/openclaw-integrity-pin-suite.ts +++ b/test/agents/openclaw/openclaw-integrity-pin-suite.ts @@ -460,7 +460,7 @@ function runInstallBlock( .replaceAll("/scripts/lib/openclaw-npm-remediation.mts", remediationHelper) .replaceAll( "bash /scripts/lib/verify-mcporter-audit.sh", - `node --experimental-strip-types ${auditHelper} --directory ${mcporterRuntime} --exceptions ${auditExceptionFile} --graph mcporter-runtime --threshold high`, + `node --experimental-strip-types ${auditHelper} --directory ${mcporterRuntime} --exceptions ${auditExceptionFile} --graph mcporter-runtime --threshold high --report /tmp/mcporter-npm-audit.json --result /tmp/mcporter-npm-audit-policy.json`, ) .replaceAll("/scripts/lib/reviewed-npm-audit.mts", auditHelper) .replaceAll("/scripts/npm-audit-exceptions.json", auditExceptionFile), From bdd9385fd75fed48ed87f854e5f1cc19813fd800 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:16:44 -0700 Subject: [PATCH 22/56] test(audit): execute protected mcporter handoff Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../reviewed-npm-audit-workflow.test.ts | 161 +++++++++++++++--- 1 file changed, 139 insertions(+), 22 deletions(-) diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 8312cd1ab2d..9833d073923 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -55,6 +55,12 @@ const REVIEWED_AUDIT_CONFIG_SOURCE = fs.readFileSync( const REVIEWED_AUDIT_CONFIG = parseAuditConfig(REVIEWED_AUDIT_CONFIG_SOURCE); type ConsolidatedAuditFixture = Readonly<{ + handoff?: Readonly<{ + accepted: ReturnType; + rejected: ReturnType; + retainedReport?: string; + retainedResult?: Record; + }>; npmCalls: readonly string[]; lockedReceipt?: string; lockedRawReport?: Buffer; @@ -74,6 +80,7 @@ function runConsolidatedAuditFixture( auditStatus = 0, offlinePackStatus = 0, observedNpmVersion = REVIEWED_AUDIT_CONFIG.npmVersion, + verifyMcporterHandoff = false, ): ConsolidatedAuditFixture { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-audit-entry-")); const trustedRoot = path.join(root, "trusted"); @@ -82,11 +89,22 @@ function runConsolidatedAuditFixture( const cacheModesFile = path.join(root, "cache-modes"); const callsFile = path.join(root, "npm-calls"); const artifactDirectory = path.join(targetRoot, "artifacts", "reviewed-npm-audit"); + const selectedMcporterGraph = REVIEWED_AUDIT_CONFIG.lockedGraphs.find( + ({ id }) => id === "mcporter-runtime", + ); + if (verifyMcporterHandoff && !selectedMcporterGraph) { + throw new Error("reviewed mcporter graph is missing"); + } try { fs.mkdirSync(path.join(trustedRoot, "ci"), { recursive: true }); fs.mkdirSync(path.join(targetRoot, "agents", "openclaw", "wechat-runtime"), { recursive: true, }); + if (verifyMcporterHandoff) { + fs.mkdirSync(path.join(targetRoot, "agents", "openclaw", "mcporter-runtime"), { + recursive: true, + }); + } fs.mkdirSync(bin); fs.cpSync(path.join(REPO_ROOT, "scripts"), path.join(trustedRoot, "scripts"), { recursive: true, @@ -116,22 +134,24 @@ function runConsolidatedAuditFixture( archiveTarVersion: "7.5.21", artifactDirectory: "artifacts/reviewed-npm-audit", exceptionFile: "ci/npm-audit-exceptions.json", - lockedGraphs: [ - { - directory: "agents/openclaw/wechat-runtime", - id: "wechat-runtime", - inputValidation: "wechat-runtime", - installMode: "legacy-peer-deps", - integrity, - label: "WeChat fixture", - lockSha256: createHash("sha256").update(runtimeLock).digest("hex"), - packageSpec: "@tencent-weixin/openclaw-weixin@2.4.3", - severityThreshold: "low", - signatureAudit: "retry-download-failures", - tarballUrl: - "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz", - }, - ], + lockedGraphs: verifyMcporterHandoff + ? [selectedMcporterGraph!] + : [ + { + directory: "agents/openclaw/wechat-runtime", + id: "wechat-runtime", + inputValidation: "wechat-runtime", + installMode: "legacy-peer-deps", + integrity, + label: "WeChat fixture", + lockSha256: createHash("sha256").update(runtimeLock).digest("hex"), + packageSpec: "@tencent-weixin/openclaw-weixin@2.4.3", + severityThreshold: "low", + signatureAudit: "retry-download-failures", + tarballUrl: + "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz", + }, + ], nodeVersion: process.version.slice(1), npmIntegrity: REVIEWED_AUDIT_CONFIG.npmIntegrity, npmVersion: REVIEWED_AUDIT_CONFIG.npmVersion, @@ -163,6 +183,14 @@ function runConsolidatedAuditFixture( path.join(targetRoot, "agents/openclaw/wechat-runtime/package-lock.json"), runtimeLock, ); + if (verifyMcporterHandoff) { + for (const file of ["package.json", "package-lock.json"]) { + fs.copyFileSync( + path.join(REPO_ROOT, "agents", "openclaw", "mcporter-runtime", file), + path.join(targetRoot, "agents", "openclaw", "mcporter-runtime", file), + ); + } + } mutateTarget(targetRoot); fs.writeFileSync( path.join(bin, "npm"), @@ -236,6 +264,9 @@ process.exit(0); encoding: "utf-8", env: { ...process.env, + ...(verifyMcporterHandoff + ? { NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH: "mcporter-runtime" } + : {}), NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR: "artifacts/reviewed-npm-audit", NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT: targetRoot, NEMOCLAW_TEST_AUDIT_OUTPUT: auditOutput, @@ -244,18 +275,81 @@ process.exit(0); NEMOCLAW_TEST_NPM_CALLS: callsFile, NEMOCLAW_TEST_NPM_VERSION: observedNpmVersion, NEMOCLAW_TEST_OFFLINE_PACK_STATUS: String(offlinePackStatus), - NEMOCLAW_TEST_REVIEWED_INTEGRITY: integrity, - NEMOCLAW_TEST_REVIEWED_TARBALL: - "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz", + NEMOCLAW_TEST_REVIEWED_INTEGRITY: verifyMcporterHandoff + ? selectedMcporterGraph!.integrity + : integrity, + NEMOCLAW_TEST_REVIEWED_TARBALL: verifyMcporterHandoff + ? selectedMcporterGraph!.tarballUrl + : "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz", PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, }, }, ); const provenanceFile = path.join(artifactDirectory, "source-graph.provenance.json"); - const receiptFile = path.join(artifactDirectory, "wechat-runtime.receipt.json"); - const rawReportFile = path.join(artifactDirectory, "wechat-runtime.raw.json"); - const lockedDirectory = path.join(targetRoot, "agents", "openclaw", "wechat-runtime"); + const lockedGraph = verifyMcporterHandoff ? "mcporter-runtime" : "wechat-runtime"; + const receiptFile = path.join(artifactDirectory, `${lockedGraph}.receipt.json`); + const rawReportFile = path.join(artifactDirectory, `${lockedGraph}.raw.json`); + const lockedDirectory = path.join(targetRoot, "agents", "openclaw", lockedGraph); + let handoff: ConsolidatedAuditFixture["handoff"]; + if (verifyMcporterHandoff && result.status === 0) { + const rawReport = fs.readFileSync(rawReportFile); + const retainedReport = path.join(root, "retained-report.json"); + const retainedResult = path.join(root, "retained-result.json"); + const helper = path.join(root, "verify-mcporter-audit.sh"); + const exceptionFile = path.join(trustedRoot, "ci", "npm-audit-exceptions.json"); + const auditConfigFile = path.join(trustedRoot, "ci", "reviewed-npm-audit.json"); + const helperSource = fs + .readFileSync(path.join(trustedRoot, "scripts/lib/verify-mcporter-audit.sh"), "utf8") + .replaceAll("/run/secrets/nemoclaw-mcporter-audit-receipt", receiptFile) + .replaceAll("/run/secrets/nemoclaw-mcporter-audit-raw-report", rawReportFile) + .replaceAll( + "/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", + path.join(root, "no-seed"), + ) + .replaceAll( + "/scripts/lib/npm-audit-receipt.mts", + path.join(trustedRoot, "scripts/lib/npm-audit-receipt.mts"), + ) + .replaceAll( + "/usr/local/lib/nemoclaw/mcporter-runtime/package.json", + path.join(lockedDirectory, "package.json"), + ) + .replaceAll( + "/usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json", + path.join(lockedDirectory, "package-lock.json"), + ) + .replaceAll("/scripts/npm-audit-exceptions.json", exceptionFile) + .replaceAll("/scripts/reviewed-npm-audit.json", auditConfigFile); + fs.writeFileSync(helper, helperSource, { mode: 0o755 }); + const runHelper = () => + spawnSync("bash", [helper], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: createHash("sha256") + .update(fs.readFileSync(receiptFile)) + .digest("hex"), + NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, + NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, + }, + }); + fs.writeFileSync(rawReportFile, "{}\n"); + const rejected = runHelper(); + fs.writeFileSync(rawReportFile, rawReport); + const accepted = runHelper(); + handoff = { + accepted, + rejected, + retainedReport: fs.existsSync(retainedReport) + ? fs.readFileSync(retainedReport, "utf8") + : undefined, + retainedResult: fs.existsSync(retainedResult) + ? (JSON.parse(fs.readFileSync(retainedResult, "utf8")) as Record) + : undefined, + }; + } return { + handoff, lockedReceipt: fs.existsSync(receiptFile) ? fs.readFileSync(receiptFile, "utf-8") : undefined, lockedRawReport: fs.existsSync(rawReportFile) ? fs.readFileSync(rawReportFile) : undefined, lockedPackageJson: fs.readFileSync(path.join(lockedDirectory, "package.json")), @@ -354,6 +448,29 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { expect(fixture.lockedReceipt).toBeUndefined(); }); + it("passes the selected mcporter producer evidence through the protected handoff", () => { + const fixture = runConsolidatedAuditFixture( + () => {}, + undefined, + 0, + 0, + REVIEWED_AUDIT_CONFIG.npmVersion, + true, + ); + + expect(fixture.result.status, fixture.result.stderr).toBe(0); + expect(fixture.lockedReceipt).toBeDefined(); + expect(fixture.npmCalls.some((call) => call.includes('"pack"'))).toBe(false); + expect(fixture.handoff?.rejected.status).not.toBe(0); + expect(fixture.handoff?.rejected.stderr).toContain("receipt rawResponseSha256 does not match"); + expect(fixture.handoff?.accepted.status, fixture.handoff?.accepted.stderr).toBe(0); + expect(fixture.handoff?.retainedReport).toBe(fixture.lockedRawReport?.toString()); + expect(fixture.handoff?.retainedResult).toMatchObject({ + graph: "mcporter-runtime", + status: "clean", + }); + }); + it("restores the read-only trusted cache after offline packing fails", () => { const fixture = runConsolidatedAuditFixture(() => {}, undefined, 0, 9); From e165162f8121ca4bcbdf90fb936427cb82bddb66 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:25:13 -0700 Subject: [PATCH 23/56] test(audit): keep producer handoff coverage lean Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../reviewed-npm-audit-handoff.test.ts | 116 ++++++++----- .../reviewed-npm-audit-workflow.test.ts | 161 +++--------------- 2 files changed, 98 insertions(+), 179 deletions(-) diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 7782cc35072..7e607b73ef1 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -9,7 +9,6 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import YAML from "yaml"; -import { emitAuditReceipt } from "../../../scripts/audit-reviewed-npm-graph.mts"; const REPO_ROOT = path.join(import.meta.dirname, "../../.."); const TRUSTED_WORKFLOWS = [ @@ -92,52 +91,89 @@ describe("reviewed npm audit handoff", () => { it("passes producer output through protected audit handoffs and rejects forged reports", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")); - const packageJsonFile = path.join(root, "package.json"); - const packageLockFile = path.join(root, "package-lock.json"); - const rawReportFile = path.join(root, "report.json"); - const runtime = path.join(REPO_ROOT, "agents/openclaw/mcporter-runtime"); - const exceptionFile = path.join(REPO_ROOT, "ci/npm-audit-exceptions.json"); - const auditConfigFile = path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"); - const packageJson = fs.readFileSync(path.join(runtime, "package.json")); - const packageLock = fs.readFileSync(path.join(runtime, "package-lock.json")); - const exceptionPolicy = fs.readFileSync(exceptionFile, "utf8"); - const npmVersion = JSON.parse(fs.readFileSync(auditConfigFile, "utf8")).npmVersion as string; + const trustedRoot = path.join(root, "trusted"); + const targetRoot = path.join(root, "target"); + const runtime = path.join(targetRoot, "agents/openclaw/mcporter-runtime"); + const artifactDirectory = path.join(targetRoot, "artifacts/reviewed-npm-audit"); + const producerBin = path.join(root, "producer-bin"); + const exceptionFile = path.join(trustedRoot, "ci/npm-audit-exceptions.json"); + const auditConfigFile = path.join(trustedRoot, "ci/reviewed-npm-audit.json"); + const auditConfig = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), + ); + const npmVersion = auditConfig.npmVersion as string; + const reviewedMcporter = auditConfig.lockedGraphs.find( + ({ id }: { id: string }) => id === "mcporter-runtime", + ); const rawReport = '{"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n'; try { - fs.writeFileSync(packageJsonFile, packageJson); - fs.writeFileSync(packageLockFile, packageLock); - fs.writeFileSync(rawReportFile, rawReport); + fs.mkdirSync(runtime, { recursive: true }); + fs.mkdirSync(path.join(trustedRoot, "ci"), { recursive: true }); + fs.mkdirSync(producerBin); + fs.cpSync(path.join(REPO_ROOT, "scripts"), path.join(trustedRoot, "scripts"), { + recursive: true, + }); + fs.cpSync(path.join(REPO_ROOT, "agents/openclaw/mcporter-runtime"), runtime, { + recursive: true, + }); + fs.copyFileSync(path.join(REPO_ROOT, "ci/npm-audit-exceptions.json"), exceptionFile); + fs.copyFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), auditConfigFile); fs.writeFileSync( - path.join(root, "report.provenance.json"), - JSON.stringify({ run: { startedAt: new Date().toISOString() } }), + path.join(producerBin, "npm"), + `#!/usr/bin/env node +const fs = require("node:fs"); +const args = process.argv.slice(2); +if (args[0] === "--version") console.log(process.env.NEMOCLAW_TEST_NPM_VERSION); +else if (args[0] === "config") console.log("https://registry.npmjs.org/"); +else if (args[0] === "view") console.log(args.includes("dist.tarball") ? process.env.NEMOCLAW_TEST_TARBALL : process.env.NEMOCLAW_TEST_INTEGRITY); +else if (args[0] === "audit" && args[1] !== "signatures") process.stdout.write(process.env.NEMOCLAW_TEST_AUDIT_OUTPUT); +else if (args[0] === "ci") { + const lock = JSON.parse(fs.readFileSync("package-lock.json", "utf8")); + for (const [location, entry] of Object.entries(lock.packages)) { + if (!location) continue; + fs.mkdirSync(location, { recursive: true }); + fs.writeFileSync(location + "/package.json", JSON.stringify({ + name: location.slice(location.lastIndexOf("node_modules/") + 13), + version: entry.version, + dependencies: entry.dependencies, + peerDependencies: entry.peerDependencies, + peerDependenciesMeta: entry.peerDependenciesMeta, + })); + } +} +`, + { mode: 0o755 }, ); - const receiptFile = emitAuditReceipt({ - artifactDirectory: root, - graphId: "mcporter-runtime", - npmVersion, - packageJsonFile, - packageLockFile, - preserveInputs: true, - rawReportFile, - registryOrigin: "https://registry.yarnpkg.com", - result: { - acceptedAdvisories: [], - blockingThreshold: "high", - exceptionPolicySha256: createHash("sha256").update(exceptionPolicy).digest("hex"), - graph: "mcporter-runtime", - reported: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, - schemaVersion: 1, - status: "clean", - unacceptedBlockingAdvisories: [], + const producer = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + path.join(trustedRoot, "scripts/audit-reviewed-npm-graph.mts"), + ], + { + cwd: trustedRoot, + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH: "mcporter-runtime", + NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR: "artifacts/reviewed-npm-audit", + NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT: targetRoot, + NEMOCLAW_TEST_AUDIT_OUTPUT: rawReport, + NEMOCLAW_TEST_INTEGRITY: reviewedMcporter.integrity, + NEMOCLAW_TEST_NPM_VERSION: npmVersion, + NEMOCLAW_TEST_TARBALL: reviewedMcporter.tarballUrl, + PATH: `${producerBin}:${process.env.PATH ?? ""}`, + }, }, - threshold: "high", - }); + ); + expect(producer.status, producer.stderr).toBe(0); - const retainedPackageJson = path.join(root, "mcporter-runtime.package.json"); - const retainedPackageLock = path.join(root, "mcporter-runtime.package-lock.json"); - const transportRawReport = path.join(root, "mcporter-runtime.raw.json"); - const receiptVerifier = path.join(REPO_ROOT, "scripts", "lib", "npm-audit-receipt.mts"); + const receiptFile = path.join(artifactDirectory, "mcporter-runtime.receipt.json"); + const retainedPackageJson = path.join(runtime, "package.json"); + const retainedPackageLock = path.join(runtime, "package-lock.json"); + const transportRawReport = path.join(artifactDirectory, "mcporter-runtime.raw.json"); + const receiptVerifier = path.join(trustedRoot, "scripts", "lib", "npm-audit-receipt.mts"); const retainedReport = path.join(root, "retained-report.json"); const retainedResult = path.join(root, "retained-result.json"); const verifierArgs = [ diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 9833d073923..8312cd1ab2d 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -55,12 +55,6 @@ const REVIEWED_AUDIT_CONFIG_SOURCE = fs.readFileSync( const REVIEWED_AUDIT_CONFIG = parseAuditConfig(REVIEWED_AUDIT_CONFIG_SOURCE); type ConsolidatedAuditFixture = Readonly<{ - handoff?: Readonly<{ - accepted: ReturnType; - rejected: ReturnType; - retainedReport?: string; - retainedResult?: Record; - }>; npmCalls: readonly string[]; lockedReceipt?: string; lockedRawReport?: Buffer; @@ -80,7 +74,6 @@ function runConsolidatedAuditFixture( auditStatus = 0, offlinePackStatus = 0, observedNpmVersion = REVIEWED_AUDIT_CONFIG.npmVersion, - verifyMcporterHandoff = false, ): ConsolidatedAuditFixture { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-audit-entry-")); const trustedRoot = path.join(root, "trusted"); @@ -89,22 +82,11 @@ function runConsolidatedAuditFixture( const cacheModesFile = path.join(root, "cache-modes"); const callsFile = path.join(root, "npm-calls"); const artifactDirectory = path.join(targetRoot, "artifacts", "reviewed-npm-audit"); - const selectedMcporterGraph = REVIEWED_AUDIT_CONFIG.lockedGraphs.find( - ({ id }) => id === "mcporter-runtime", - ); - if (verifyMcporterHandoff && !selectedMcporterGraph) { - throw new Error("reviewed mcporter graph is missing"); - } try { fs.mkdirSync(path.join(trustedRoot, "ci"), { recursive: true }); fs.mkdirSync(path.join(targetRoot, "agents", "openclaw", "wechat-runtime"), { recursive: true, }); - if (verifyMcporterHandoff) { - fs.mkdirSync(path.join(targetRoot, "agents", "openclaw", "mcporter-runtime"), { - recursive: true, - }); - } fs.mkdirSync(bin); fs.cpSync(path.join(REPO_ROOT, "scripts"), path.join(trustedRoot, "scripts"), { recursive: true, @@ -134,24 +116,22 @@ function runConsolidatedAuditFixture( archiveTarVersion: "7.5.21", artifactDirectory: "artifacts/reviewed-npm-audit", exceptionFile: "ci/npm-audit-exceptions.json", - lockedGraphs: verifyMcporterHandoff - ? [selectedMcporterGraph!] - : [ - { - directory: "agents/openclaw/wechat-runtime", - id: "wechat-runtime", - inputValidation: "wechat-runtime", - installMode: "legacy-peer-deps", - integrity, - label: "WeChat fixture", - lockSha256: createHash("sha256").update(runtimeLock).digest("hex"), - packageSpec: "@tencent-weixin/openclaw-weixin@2.4.3", - severityThreshold: "low", - signatureAudit: "retry-download-failures", - tarballUrl: - "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz", - }, - ], + lockedGraphs: [ + { + directory: "agents/openclaw/wechat-runtime", + id: "wechat-runtime", + inputValidation: "wechat-runtime", + installMode: "legacy-peer-deps", + integrity, + label: "WeChat fixture", + lockSha256: createHash("sha256").update(runtimeLock).digest("hex"), + packageSpec: "@tencent-weixin/openclaw-weixin@2.4.3", + severityThreshold: "low", + signatureAudit: "retry-download-failures", + tarballUrl: + "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz", + }, + ], nodeVersion: process.version.slice(1), npmIntegrity: REVIEWED_AUDIT_CONFIG.npmIntegrity, npmVersion: REVIEWED_AUDIT_CONFIG.npmVersion, @@ -183,14 +163,6 @@ function runConsolidatedAuditFixture( path.join(targetRoot, "agents/openclaw/wechat-runtime/package-lock.json"), runtimeLock, ); - if (verifyMcporterHandoff) { - for (const file of ["package.json", "package-lock.json"]) { - fs.copyFileSync( - path.join(REPO_ROOT, "agents", "openclaw", "mcporter-runtime", file), - path.join(targetRoot, "agents", "openclaw", "mcporter-runtime", file), - ); - } - } mutateTarget(targetRoot); fs.writeFileSync( path.join(bin, "npm"), @@ -264,9 +236,6 @@ process.exit(0); encoding: "utf-8", env: { ...process.env, - ...(verifyMcporterHandoff - ? { NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH: "mcporter-runtime" } - : {}), NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR: "artifacts/reviewed-npm-audit", NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT: targetRoot, NEMOCLAW_TEST_AUDIT_OUTPUT: auditOutput, @@ -275,81 +244,18 @@ process.exit(0); NEMOCLAW_TEST_NPM_CALLS: callsFile, NEMOCLAW_TEST_NPM_VERSION: observedNpmVersion, NEMOCLAW_TEST_OFFLINE_PACK_STATUS: String(offlinePackStatus), - NEMOCLAW_TEST_REVIEWED_INTEGRITY: verifyMcporterHandoff - ? selectedMcporterGraph!.integrity - : integrity, - NEMOCLAW_TEST_REVIEWED_TARBALL: verifyMcporterHandoff - ? selectedMcporterGraph!.tarballUrl - : "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz", + NEMOCLAW_TEST_REVIEWED_INTEGRITY: integrity, + NEMOCLAW_TEST_REVIEWED_TARBALL: + "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz", PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, }, }, ); const provenanceFile = path.join(artifactDirectory, "source-graph.provenance.json"); - const lockedGraph = verifyMcporterHandoff ? "mcporter-runtime" : "wechat-runtime"; - const receiptFile = path.join(artifactDirectory, `${lockedGraph}.receipt.json`); - const rawReportFile = path.join(artifactDirectory, `${lockedGraph}.raw.json`); - const lockedDirectory = path.join(targetRoot, "agents", "openclaw", lockedGraph); - let handoff: ConsolidatedAuditFixture["handoff"]; - if (verifyMcporterHandoff && result.status === 0) { - const rawReport = fs.readFileSync(rawReportFile); - const retainedReport = path.join(root, "retained-report.json"); - const retainedResult = path.join(root, "retained-result.json"); - const helper = path.join(root, "verify-mcporter-audit.sh"); - const exceptionFile = path.join(trustedRoot, "ci", "npm-audit-exceptions.json"); - const auditConfigFile = path.join(trustedRoot, "ci", "reviewed-npm-audit.json"); - const helperSource = fs - .readFileSync(path.join(trustedRoot, "scripts/lib/verify-mcporter-audit.sh"), "utf8") - .replaceAll("/run/secrets/nemoclaw-mcporter-audit-receipt", receiptFile) - .replaceAll("/run/secrets/nemoclaw-mcporter-audit-raw-report", rawReportFile) - .replaceAll( - "/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", - path.join(root, "no-seed"), - ) - .replaceAll( - "/scripts/lib/npm-audit-receipt.mts", - path.join(trustedRoot, "scripts/lib/npm-audit-receipt.mts"), - ) - .replaceAll( - "/usr/local/lib/nemoclaw/mcporter-runtime/package.json", - path.join(lockedDirectory, "package.json"), - ) - .replaceAll( - "/usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json", - path.join(lockedDirectory, "package-lock.json"), - ) - .replaceAll("/scripts/npm-audit-exceptions.json", exceptionFile) - .replaceAll("/scripts/reviewed-npm-audit.json", auditConfigFile); - fs.writeFileSync(helper, helperSource, { mode: 0o755 }); - const runHelper = () => - spawnSync("bash", [helper], { - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: createHash("sha256") - .update(fs.readFileSync(receiptFile)) - .digest("hex"), - NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, - NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, - }, - }); - fs.writeFileSync(rawReportFile, "{}\n"); - const rejected = runHelper(); - fs.writeFileSync(rawReportFile, rawReport); - const accepted = runHelper(); - handoff = { - accepted, - rejected, - retainedReport: fs.existsSync(retainedReport) - ? fs.readFileSync(retainedReport, "utf8") - : undefined, - retainedResult: fs.existsSync(retainedResult) - ? (JSON.parse(fs.readFileSync(retainedResult, "utf8")) as Record) - : undefined, - }; - } + const receiptFile = path.join(artifactDirectory, "wechat-runtime.receipt.json"); + const rawReportFile = path.join(artifactDirectory, "wechat-runtime.raw.json"); + const lockedDirectory = path.join(targetRoot, "agents", "openclaw", "wechat-runtime"); return { - handoff, lockedReceipt: fs.existsSync(receiptFile) ? fs.readFileSync(receiptFile, "utf-8") : undefined, lockedRawReport: fs.existsSync(rawReportFile) ? fs.readFileSync(rawReportFile) : undefined, lockedPackageJson: fs.readFileSync(path.join(lockedDirectory, "package.json")), @@ -448,29 +354,6 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { expect(fixture.lockedReceipt).toBeUndefined(); }); - it("passes the selected mcporter producer evidence through the protected handoff", () => { - const fixture = runConsolidatedAuditFixture( - () => {}, - undefined, - 0, - 0, - REVIEWED_AUDIT_CONFIG.npmVersion, - true, - ); - - expect(fixture.result.status, fixture.result.stderr).toBe(0); - expect(fixture.lockedReceipt).toBeDefined(); - expect(fixture.npmCalls.some((call) => call.includes('"pack"'))).toBe(false); - expect(fixture.handoff?.rejected.status).not.toBe(0); - expect(fixture.handoff?.rejected.stderr).toContain("receipt rawResponseSha256 does not match"); - expect(fixture.handoff?.accepted.status, fixture.handoff?.accepted.stderr).toBe(0); - expect(fixture.handoff?.retainedReport).toBe(fixture.lockedRawReport?.toString()); - expect(fixture.handoff?.retainedResult).toMatchObject({ - graph: "mcporter-runtime", - status: "clean", - }); - }); - it("restores the read-only trusted cache after offline packing fails", () => { const fixture = runConsolidatedAuditFixture(() => {}, undefined, 0, 9); From b6dce0c57f173a6f6834497cc9e09d786d4a33b7 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:23:36 -0700 Subject: [PATCH 24/56] fix(images): diagnose empty audit receipt hashes Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- scripts/checks/build-protected-managed-images.sh | 2 +- .../images/protected-managed-image-build-script.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index d455e362121..7aa23dd0a44 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -215,7 +215,7 @@ validate_audit_evidence() { exit 1 } local recorded_hash - read -r recorded_hash <"$hash_file" + read -r recorded_hash <"$hash_file" || recorded_hash="" audit_receipt_sha256="$(sha256sum "$audit_receipt" | awk '{print $1}')" [[ "$recorded_hash" =~ ^[a-f0-9]{64}$ && "$recorded_hash" == "$audit_receipt_sha256" ]] || { echo "ERROR: protected managed-image reviewed audit receipt hash does not match" >&2 diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index 40d07544ee2..a72cac16991 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -628,6 +628,15 @@ describe("protected managed-image build-cache boundary", () => { ), "reviewed audit receipt hash does not match", ], + [ + "empty", + (cacheRoot: string) => + writeFileSync( + path.join(cacheRoot, "reviewed-npm-audit", "mcporter-runtime.receipt.sha256"), + "", + ), + "reviewed audit receipt hash does not match", + ], ])( "rejects %s reviewed audit evidence before invoking Docker (#11088)", (_case, mutate, error) => { From ef37a0a589d627ca9e31ebaf69cea767f58d75bc Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:34:28 -0700 Subject: [PATCH 25/56] fix(e2e): run protected cache consumer from candidate Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .github/workflows/e2e.yaml | 2 +- .../managed-image-protected-runtime-workflow.test.ts | 10 ++++++++++ ...naged-image-protected-runtime-workflow-boundary.mts | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 04ee7d70595..6294e9fda63 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -5184,7 +5184,7 @@ jobs: shell: bash run: | set -euo pipefail - scripts/checks/build-protected-managed-images.sh \ + "$GITHUB_WORKSPACE/.candidate-runtime/scripts/checks/build-protected-managed-images.sh" \ --output "$NEMOCLAW_PROTECTED_MANAGED_IMAGE_CONTRACT" \ --revision "$CHECKOUT_SHA" \ --cohort "$NEMOCLAW_PROTECTED_MANAGED_IMAGE_COHORT" \ diff --git a/test/e2e/support/managed-image-protected-runtime-workflow.test.ts b/test/e2e/support/managed-image-protected-runtime-workflow.test.ts index 6857fd53092..861822882d1 100644 --- a/test/e2e/support/managed-image-protected-runtime-workflow.test.ts +++ b/test/e2e/support/managed-image-protected-runtime-workflow.test.ts @@ -412,6 +412,16 @@ describe("protected managed-image runtime workflow", () => { ); }); + it("requires the protected runtime to use the candidate cache consumer", () => { + const value = workflow(); + const build = namedStep(value, "Build exact all-agent protected runtime images"); + build.run = String(build.run).replace("$GITHUB_WORKSPACE/.candidate-runtime/", ""); + + expect(validateManagedImageProtectedRuntimeWorkflow(value)).toContain( + "managed-image-protected-runtime step 'Build exact all-agent protected runtime images' must include \"$GITHUB_WORKSPACE/.candidate-runtime/scripts/checks/build-protected-managed-images.sh\"", + ); + }); + it("rejects a hosted producer that is not selected with protected runtime", () => { const value = workflow(); multiarchJob(value).if = diff --git a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts index 44adab85bf8..dc7aa8d8212 100644 --- a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts +++ b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts @@ -311,7 +311,7 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR "Build exact all-agent protected runtime images", ); requireFragments(errors, build, [ - "scripts/checks/build-protected-managed-images.sh", + '"$GITHUB_WORKSPACE/.candidate-runtime/scripts/checks/build-protected-managed-images.sh"', '--revision "$CHECKOUT_SHA"', '--cohort "$NEMOCLAW_PROTECTED_MANAGED_IMAGE_COHORT"', "--platform linux/amd64", From f9469001800f4386c75d7ebb448194aeaf73bb05 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:49:37 -0700 Subject: [PATCH 26/56] fix(ci): isolate candidate image build from docker auth --- .github/workflows/e2e.yaml | 7 ++++++- ...ged-image-protected-runtime-workflow.test.ts | 17 +++++++++++++++++ ...mage-protected-runtime-workflow-boundary.mts | 10 ++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 6294e9fda63..9c2d26cb831 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -5175,6 +5175,10 @@ jobs: docker logs "$NEMOCLAW_PROTECTED_REGISTRY_NAME" >&2 exit 1 + - name: Remove Docker auth before candidate build + shell: bash + run: bash .github/scripts/docker-auth-cleanup.sh + - name: Build exact all-agent protected runtime images env: BASE_DCODE: ${{ steps.runtime-bases.outputs.dcode }} @@ -5184,7 +5188,8 @@ jobs: shell: bash run: | set -euo pipefail - "$GITHUB_WORKSPACE/.candidate-runtime/scripts/checks/build-protected-managed-images.sh" \ + env -u DOCKER_CONFIG -u DOCKERHUB_USERNAME -u DOCKERHUB_TOKEN \ + "$GITHUB_WORKSPACE/.candidate-runtime/scripts/checks/build-protected-managed-images.sh" \ --output "$NEMOCLAW_PROTECTED_MANAGED_IMAGE_CONTRACT" \ --revision "$CHECKOUT_SHA" \ --cohort "$NEMOCLAW_PROTECTED_MANAGED_IMAGE_COHORT" \ diff --git a/test/e2e/support/managed-image-protected-runtime-workflow.test.ts b/test/e2e/support/managed-image-protected-runtime-workflow.test.ts index 861822882d1..b7c19576879 100644 --- a/test/e2e/support/managed-image-protected-runtime-workflow.test.ts +++ b/test/e2e/support/managed-image-protected-runtime-workflow.test.ts @@ -422,6 +422,23 @@ describe("protected managed-image runtime workflow", () => { ); }); + it("removes Docker Hub credentials before candidate code runs", () => { + const value = workflow(); + const steps = runtimeJob(value).steps as Array>; + const cleanupIndex = steps.findIndex( + (step) => step.name === "Remove Docker auth before candidate build", + ); + const [cleanup] = steps.splice(cleanupIndex, 1); + const buildIndex = steps.findIndex( + (step) => step.name === "Build exact all-agent protected runtime images", + ); + steps.splice(buildIndex + 1, 0, cleanup); + + expect(validateManagedImageProtectedRuntimeWorkflow(value)).toContain( + "managed-image-protected-runtime protected qualification and cleanup steps drifted", + ); + }); + it("rejects a hosted producer that is not selected with protected runtime", () => { const value = workflow(); multiarchJob(value).if = diff --git a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts index dc7aa8d8212..1cf37670417 100644 --- a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts +++ b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts @@ -311,6 +311,7 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR "Build exact all-agent protected runtime images", ); requireFragments(errors, build, [ + "env -u DOCKER_CONFIG -u DOCKERHUB_USERNAME -u DOCKERHUB_TOKEN", '"$GITHUB_WORKSPACE/.candidate-runtime/scripts/checks/build-protected-managed-images.sh"', '--revision "$CHECKOUT_SHA"', '--cohort "$NEMOCLAW_PROTECTED_MANAGED_IMAGE_COHORT"', @@ -378,6 +379,14 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR path: "e2e-artifacts/live/managed-image-protected-runtime/", }); requireStep(errors, workflowSteps, "Clean up Docker auth"); + const prebuildAuthCleanup = requireStep( + errors, + workflowSteps, + "Remove Docker auth before candidate build", + ); + requireFragments(errors, prebuildAuthCleanup, [ + "bash .github/scripts/docker-auth-cleanup.sh", + ]); requireOrderedSteps(errors, workflowSteps, [ "Validate protected runtime exact-head dispatch", "Checkout trusted protected runtime qualification", @@ -388,6 +397,7 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR "Resolve reviewed Hermes runtime base image", "Resolve digest-pinned amd64 runtime base images", "Start isolated protected runtime registry", + "Remove Docker auth before candidate build", "Build exact all-agent protected runtime images", "Install OpenShell CLI", "Run all-agent GPU, local inference, rollback, and cleanup qualification", From 4ba522bc30d7711058779d4b75ced12347223eef Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 8 Sep 2026 21:59:15 -0400 Subject: [PATCH 27/56] test(audit): canonicalize handoff temp path --- test/automation/releases/reviewed-npm-audit-handoff.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 7e607b73ef1..46648e0050e 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -90,7 +90,9 @@ describe("reviewed npm audit handoff", () => { ); it("passes producer output through protected audit handoffs and rejects forged reports", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")); + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")), + ); const trustedRoot = path.join(root, "trusted"); const targetRoot = path.join(root, "target"); const runtime = path.join(targetRoot, "agents/openclaw/mcporter-runtime"); From 070c6a0df5df75278ee9046ce2c4bea942456404 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:02:08 -0700 Subject: [PATCH 28/56] fix(ci): preserve trusted protected controller --- .github/workflows/e2e.yaml | 7 +----- agents/openclaw/dependency-review.md | 2 +- ...d-image-protected-runtime-workflow.test.ts | 24 ++++--------------- ...ge-protected-runtime-workflow-boundary.mts | 19 +++++++-------- 4 files changed, 15 insertions(+), 37 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 9c2d26cb831..04ee7d70595 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -5175,10 +5175,6 @@ jobs: docker logs "$NEMOCLAW_PROTECTED_REGISTRY_NAME" >&2 exit 1 - - name: Remove Docker auth before candidate build - shell: bash - run: bash .github/scripts/docker-auth-cleanup.sh - - name: Build exact all-agent protected runtime images env: BASE_DCODE: ${{ steps.runtime-bases.outputs.dcode }} @@ -5188,8 +5184,7 @@ jobs: shell: bash run: | set -euo pipefail - env -u DOCKER_CONFIG -u DOCKERHUB_USERNAME -u DOCKERHUB_TOKEN \ - "$GITHUB_WORKSPACE/.candidate-runtime/scripts/checks/build-protected-managed-images.sh" \ + scripts/checks/build-protected-managed-images.sh \ --output "$NEMOCLAW_PROTECTED_MANAGED_IMAGE_CONTRACT" \ --revision "$CHECKOUT_SHA" \ --cohort "$NEMOCLAW_PROTECTED_MANAGED_IMAGE_COHORT" \ diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index 633ca925c4d..66f6cb2f416 100644 --- a/agents/openclaw/dependency-review.md +++ b/agents/openclaw/dependency-review.md @@ -28,7 +28,7 @@ Update it and `agents/openclaw/mcporter-runtime/package*.json` together whenever Both image paths install the committed graph with `npm ci --ignore-scripts --omit=dev` because the published package declares no install-time lifecycle script and NemoClaw needs only its already-built CLI. The reviewed audit wrapper reports lower-severity production findings and blocks unaccepted high or critical advisories. The default `ci/npm-audit-exceptions.json` registry is empty. Any future exception must match one advisory, graph, package, installed version, and severity; identify an owner and NemoClaw tracking issue; state a decision, rationale, and expiry no more than 30 days away; and include compensating controls for temporary risk acceptance. Missing, malformed, expired, overlong, mismatched, or unused exceptions fail closed. The repository-wide audit also rejects exceptions for unknown graph IDs. Registry signature verification remains a separate control. -Managed image publication supplies the mcporter receipt and raw report as BuildKit secrets. Protected qualification produces the same evidence during its networked cache export and supplies it to the offline rebuild through the same secret boundary. A build without that evidence runs the reviewed audit directly and fails closed if completeness cannot be established. +Managed image publication supplies the mcporter receipt and raw report as BuildKit secrets. Protected qualification produces the same evidence during its networked cache export. Its offline consumer rejects missing or invalid cached evidence before building an image. Other image builds without paired evidence run the reviewed audit directly and fail closed if completeness cannot be established. ## WeChat plugin runtime graph diff --git a/test/e2e/support/managed-image-protected-runtime-workflow.test.ts b/test/e2e/support/managed-image-protected-runtime-workflow.test.ts index b7c19576879..865d15de9f3 100644 --- a/test/e2e/support/managed-image-protected-runtime-workflow.test.ts +++ b/test/e2e/support/managed-image-protected-runtime-workflow.test.ts @@ -412,30 +412,16 @@ describe("protected managed-image runtime workflow", () => { ); }); - it("requires the protected runtime to use the candidate cache consumer", () => { + it("keeps the protected build controller in the trusted checkout", () => { const value = workflow(); const build = namedStep(value, "Build exact all-agent protected runtime images"); - build.run = String(build.run).replace("$GITHUB_WORKSPACE/.candidate-runtime/", ""); - - expect(validateManagedImageProtectedRuntimeWorkflow(value)).toContain( - "managed-image-protected-runtime step 'Build exact all-agent protected runtime images' must include \"$GITHUB_WORKSPACE/.candidate-runtime/scripts/checks/build-protected-managed-images.sh\"", - ); - }); - - it("removes Docker Hub credentials before candidate code runs", () => { - const value = workflow(); - const steps = runtimeJob(value).steps as Array>; - const cleanupIndex = steps.findIndex( - (step) => step.name === "Remove Docker auth before candidate build", - ); - const [cleanup] = steps.splice(cleanupIndex, 1); - const buildIndex = steps.findIndex( - (step) => step.name === "Build exact all-agent protected runtime images", + build.run = String(build.run).replace( + "scripts/checks/build-protected-managed-images.sh", + '"$GITHUB_WORKSPACE/.candidate-runtime/scripts/checks/build-protected-managed-images.sh"', ); - steps.splice(buildIndex + 1, 0, cleanup); expect(validateManagedImageProtectedRuntimeWorkflow(value)).toContain( - "managed-image-protected-runtime protected qualification and cleanup steps drifted", + "managed-image-protected-runtime build controller must execute trusted workflow code", ); }); diff --git a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts index 1cf37670417..2398e37e5d4 100644 --- a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts +++ b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts @@ -311,8 +311,7 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR "Build exact all-agent protected runtime images", ); requireFragments(errors, build, [ - "env -u DOCKER_CONFIG -u DOCKERHUB_USERNAME -u DOCKERHUB_TOKEN", - '"$GITHUB_WORKSPACE/.candidate-runtime/scripts/checks/build-protected-managed-images.sh"', + "scripts/checks/build-protected-managed-images.sh", '--revision "$CHECKOUT_SHA"', '--cohort "$NEMOCLAW_PROTECTED_MANAGED_IMAGE_COHORT"', "--platform linux/amd64", @@ -322,6 +321,13 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR '--hermes-base "$BASE_HERMES"', '--dcode-base "$BASE_DCODE"', ]); + if ( + text(build?.run).includes( + ".candidate-runtime/scripts/checks/build-protected-managed-images.sh", + ) + ) { + errors.push(`${JOB_ID} build controller must execute trusted workflow code`); + } requireValues(errors, `${JOB_ID} protected runtime build bases`, record(build?.env), { BASE_HERMES: "ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@${{ steps.runtime-hermes-base.outputs.digest }}", @@ -379,14 +385,6 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR path: "e2e-artifacts/live/managed-image-protected-runtime/", }); requireStep(errors, workflowSteps, "Clean up Docker auth"); - const prebuildAuthCleanup = requireStep( - errors, - workflowSteps, - "Remove Docker auth before candidate build", - ); - requireFragments(errors, prebuildAuthCleanup, [ - "bash .github/scripts/docker-auth-cleanup.sh", - ]); requireOrderedSteps(errors, workflowSteps, [ "Validate protected runtime exact-head dispatch", "Checkout trusted protected runtime qualification", @@ -397,7 +395,6 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR "Resolve reviewed Hermes runtime base image", "Resolve digest-pinned amd64 runtime base images", "Start isolated protected runtime registry", - "Remove Docker auth before candidate build", "Build exact all-agent protected runtime images", "Install OpenShell CLI", "Run all-agent GPU, local inference, rollback, and cleanup qualification", From 07459b1e9aa9c2a5253e71bfc6c52759775e936f Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:42:19 -0700 Subject: [PATCH 29/56] fix(ci): trust protected npm audit handoff --- .../actions/ci-reviewed-npm-audit/action.yaml | 5 + .github/workflows/e2e.yaml | 60 ++++- Dockerfile.protected-npm-audit | 31 --- .../checks/build-protected-managed-images.sh | 64 +++--- .../reviewed-npm-audit-handoff.test.ts | 11 + ...d-image-protected-runtime-workflow.test.ts | 26 ++- ...managed-image-publication-workflow.test.ts | 2 + ...otected-managed-image-build-script.test.ts | 209 +++++------------- test/security/mcporter-supply-chain.test.ts | 22 +- ...ge-protected-runtime-workflow-boundary.mts | 99 ++++++++- 10 files changed, 298 insertions(+), 231 deletions(-) delete mode 100644 Dockerfile.protected-npm-audit diff --git a/.github/actions/ci-reviewed-npm-audit/action.yaml b/.github/actions/ci-reviewed-npm-audit/action.yaml index c70ce662068..512885ffdae 100644 --- a/.github/actions/ci-reviewed-npm-audit/action.yaml +++ b/.github/actions/ci-reviewed-npm-audit/action.yaml @@ -18,6 +18,10 @@ inputs: description: Whether this trusted caller may publish reusable audit records. required: false default: "false" + locked-graph: + description: Optional configured locked graph to audit instead of every reviewed graph. + required: false + default: "" runs: using: composite @@ -101,6 +105,7 @@ runs: - name: Materialize and audit reviewed npm graphs shell: bash env: + NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH: ${{ inputs.locked-graph }} NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT: ${{ inputs.target-root }} NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR: ${{ inputs.report-dir }} NEMOCLAW_REVIEWED_NPM_AUDIT_CACHE_DIR: ${{ inputs.cache-directory }} diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 04ee7d70595..132778695ae 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -4224,6 +4224,49 @@ jobs: run: bash .github/scripts/docker-auth-cleanup.sh # Manual PR qualification also requires the exact candidate activation contract. + managed-image-protected-audit: + name: Produce trusted protected mcporter audit evidence + needs: generate-matrix + if: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }} + runs-on: ubuntu-24.04 + timeout-minutes: 25 + permissions: + contents: read + steps: + - name: Checkout trusted reviewed npm audit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ github.repository }} + ref: ${{ inputs.workflow_sha || github.workflow_sha }} + persist-credentials: false + sparse-checkout: | + .github/actions/ci-reviewed-npm-audit + ci/npm-audit-exceptions.json + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/lib/npm-audit-receipt.mts + scripts/lib/repository-input-path.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts + sparse-checkout-cone-mode: false + + - name: Checkout exact protected audit target + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ inputs.checkout_repository || github.repository }} + ref: ${{ inputs.checkout_sha || github.sha }} + path: .candidate-audit + persist-credentials: false + + - name: Audit exact candidate mcporter graph from trusted code + uses: ./.github/actions/ci-reviewed-npm-audit + with: + target-root: ${{ github.workspace }}/.candidate-audit + report-dir: artifacts/reviewed-npm-audit + cache-directory: ${{ runner.temp }}/reviewed-npm-audit-cache + locked-graph: mcporter-runtime + managed-image-multiarch-startup: name: Protected managed-image startup (${{ matrix.platform }}) needs: [base-image-publication, generate-matrix] @@ -4956,8 +4999,14 @@ jobs: # assertions without the hosted cache. managed-image-protected-runtime: name: Protected managed-image GPU and local inference - needs: [base-image-publication, generate-matrix, managed-image-multiarch-startup] - if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && needs['base-image-publication'].result == 'success' && needs['generate-matrix'].result == 'success' && needs['managed-image-multiarch-startup'].result == 'success' && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }} + needs: + [ + base-image-publication, + generate-matrix, + managed-image-multiarch-startup, + managed-image-protected-audit, + ] + if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && needs['base-image-publication'].result == 'success' && needs['generate-matrix'].result == 'success' && needs['managed-image-multiarch-startup'].result == 'success' && needs['managed-image-protected-audit'].result == 'success' && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }} runs-on: linux-amd64-gpu-rtxpro6000-latest-1 timeout-minutes: 300 permissions: @@ -5040,6 +5089,12 @@ jobs: name: ${{ env.NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE_ARTIFACT }} path: ${{ env.NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE }} + - name: Download trusted protected mcporter audit evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: reviewed-npm-audit + path: ${{ runner.temp }}/protected-reviewed-npm-audit + - *dockerhub-auth - name: Set up protected runtime Buildx @@ -5191,6 +5246,7 @@ jobs: --platform linux/amd64 \ --source-root "$GITHUB_WORKSPACE/.candidate-runtime" \ --cache-from "$NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE" \ + --audit-evidence-from "$RUNNER_TEMP/protected-reviewed-npm-audit" \ --openclaw-base "$BASE_OPENCLAW" \ --hermes-base "$BASE_HERMES" \ --dcode-base "$BASE_DCODE" diff --git a/Dockerfile.protected-npm-audit b/Dockerfile.protected-npm-audit deleted file mode 100644 index e5f6ddc5b5a..00000000000 --- a/Dockerfile.protected-npm-audit +++ /dev/null @@ -1,31 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -# Produce reviewed mcporter audit evidence on the networked managed-image -# builder so the protected rebuild can stay fully offline. -FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c AS protected-mcporter-audit -ENV RUNNER_TEMP=/tmp -WORKDIR /opt/nemoclaw-audit -COPY .github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh .github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh -COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json ci/ -COPY scripts/audit-reviewed-npm-graph.mts scripts/audit-reviewed-npm-graph.mts -COPY scripts/lib/npm-audit-receipt.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/repository-input-path.mts scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/ -COPY agents/openclaw/mcporter-runtime/package.json agents/openclaw/mcporter-runtime/package-lock.json agents/openclaw/mcporter-runtime/ -# hadolint ignore=DL3016,DL4006,SC2155 -RUN --network=default set -eu; \ - export NEMOCLAW_REVIEWED_NPM_VERSION="$(node -p "require('./ci/reviewed-npm-audit.json').npmVersion")"; \ - export NEMOCLAW_REVIEWED_NPM_INTEGRITY="$(node -p "require('./ci/reviewed-npm-audit.json').npmIntegrity")"; \ - env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN \ - bash .github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh; \ - test "$(npm --version)" = "$NEMOCLAW_REVIEWED_NPM_VERSION" -RUN --network=default env \ - -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN \ - NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH=mcporter-runtime \ - NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR=artifacts/reviewed-npm-audit \ - NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ \ - NPM_CONFIG_USERCONFIG=/dev/null \ - node --experimental-strip-types scripts/audit-reviewed-npm-graph.mts - -FROM scratch AS protected-mcporter-audit-evidence -COPY --from=protected-mcporter-audit --chmod=0400 /opt/nemoclaw-audit/artifacts/reviewed-npm-audit/mcporter-runtime.receipt.json /mcporter-runtime.receipt.json -COPY --from=protected-mcporter-audit --chmod=0400 /opt/nemoclaw-audit/artifacts/reviewed-npm-audit/mcporter-runtime.raw.json /mcporter-runtime.raw.json diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index 7aa23dd0a44..3e4ae422276 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -5,7 +5,7 @@ set -euo pipefail usage() { - echo "usage: $0 --output --revision --cohort --platform --openclaw-base --hermes-base --dcode-base [--source-root ] [--cache-to ] [--cache-from ]" >&2 + echo "usage: $0 --output --revision --cohort --platform --openclaw-base --hermes-base --dcode-base [--source-root ] [--cache-to ] [--cache-from --audit-evidence-from ]" >&2 exit 2 } @@ -19,8 +19,14 @@ dcode_base="" source_root="$PWD" cache_to="" cache_from="" +audit_evidence_from="" while (($# > 0)); do case "$1" in + --audit-evidence-from) + (($# >= 2)) || usage + audit_evidence_from="$2" + shift 2 + ;; --cache-to) (($# >= 2)) || usage cache_to="$2" @@ -93,6 +99,13 @@ npm_target_libc="glibc" [[ "$dcode_base" =~ ^ghcr[.]io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base@sha256:[a-f0-9]{64}$ ]] || usage [[ "$source_root" == /* && "$source_root" != *$'\n'* && -d "$source_root" && ! -L "$source_root" ]] || usage source_root="$(cd -- "$source_root" && pwd -P)" +controller_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)" +trusted_audit_config="$controller_root/ci/reviewed-npm-audit.json" +trusted_audit_exceptions="$controller_root/ci/npm-audit-exceptions.json" +trusted_receipt_verifier="$controller_root/scripts/lib/npm-audit-receipt.mts" +[[ -f "$trusted_audit_config" && ! -L "$trusted_audit_config" ]] || usage +[[ -f "$trusted_audit_exceptions" && ! -L "$trusted_audit_exceptions" ]] || usage +[[ -f "$trusted_receipt_verifier" && ! -L "$trusted_receipt_verifier" ]] || usage seed_helper="$source_root/scripts/checks/materialize-locked-npm-cache-seed.mts" source_lockfile="$source_root/nemoclaw/package-lock.json" source_seed_dir="$source_root/tools/mcp-tool-discovery-runtime/npm-cache-seed" @@ -159,6 +172,12 @@ if [[ -n "$cache_from" ]]; then exit 1 } fi +if [[ -n "$audit_evidence_from" ]]; then + [[ -n "$cache_from" && "$audit_evidence_from" == /* && "$audit_evidence_from" != *$'\n'* && -d "$audit_evidence_from" && ! -L "$audit_evidence_from" ]] || usage + audit_evidence_from="$(cd -- "$audit_evidence_from" && pwd -P)" +elif [[ -n "$cache_from" ]]; then + usage +fi for command in curl docker jq node sha256sum; do command -v "$command" >/dev/null 2>&1 || { @@ -209,22 +228,26 @@ validate_audit_evidence() { } audit_receipt="$directory/mcporter-runtime.receipt.json" audit_raw_report="$directory/mcporter-runtime.raw.json" - local hash_file="$directory/mcporter-runtime.receipt.sha256" - [[ -f "$audit_receipt" && -s "$audit_receipt" && -f "$audit_raw_report" && -s "$audit_raw_report" && -f "$hash_file" ]] || { + [[ -f "$audit_receipt" && -s "$audit_receipt" && -f "$audit_raw_report" && -s "$audit_raw_report" ]] || { echo "ERROR: protected managed-image reviewed audit evidence is incomplete" >&2 exit 1 } - local recorded_hash - read -r recorded_hash <"$hash_file" || recorded_hash="" audit_receipt_sha256="$(sha256sum "$audit_receipt" | awk '{print $1}')" - [[ "$recorded_hash" =~ ^[a-f0-9]{64}$ && "$recorded_hash" == "$audit_receipt_sha256" ]] || { - echo "ERROR: protected managed-image reviewed audit receipt hash does not match" >&2 - exit 1 - } + node --experimental-strip-types --no-warnings "$trusted_receipt_verifier" \ + --receipt "$audit_receipt" \ + --package-json "$source_root/agents/openclaw/mcporter-runtime/package.json" \ + --package-lock "$source_root/agents/openclaw/mcporter-runtime/package-lock.json" \ + --raw-report "$audit_raw_report" \ + --exceptions "$trusted_audit_exceptions" \ + --graph mcporter-runtime \ + --audit-config "$trusted_audit_config" \ + --registry https://registry.yarnpkg.com \ + --threshold high \ + --legacy-npmjs true } if [[ -n "$cache_from" ]]; then - audit_evidence_dir="$cache_from/reviewed-npm-audit" + audit_evidence_dir="$audit_evidence_from" validate_audit_evidence "$audit_evidence_dir" fi @@ -514,27 +537,6 @@ build_agent() { }' >>"$contracts" } -if [[ -n "$cache_to" ]]; then - audit_evidence_dir="$cache_to/reviewed-npm-audit" - audit_build_command=(docker buildx build - --file "$source_root/Dockerfile.protected-npm-audit" - --platform "$platform" - --target protected-mcporter-audit-evidence - --output "type=local,dest=${audit_evidence_dir}" - --provenance=false - --sbom=false - "$source_root") - run_build_with_retry "reviewed-npm-audit" "" "$audit_evidence_dir" "${audit_build_command[@]}" - [[ -f "$audit_evidence_dir/mcporter-runtime.receipt.json" && ! -L "$audit_evidence_dir/mcporter-runtime.receipt.json" ]] || { - echo "ERROR: protected managed-image reviewed audit receipt is missing or unsafe" >&2 - exit 1 - } - sha256sum "$audit_evidence_dir/mcporter-runtime.receipt.json" | awk '{print $1}' \ - >"$audit_evidence_dir/mcporter-runtime.receipt.sha256" - chmod 0400 "$audit_evidence_dir/mcporter-runtime.receipt.sha256" - validate_audit_evidence "$audit_evidence_dir" -fi - build_agent \ openclaw \ Dockerfile \ diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 46648e0050e..0a5edf2c062 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -119,6 +119,17 @@ describe("reviewed npm audit handoff", () => { fs.cpSync(path.join(REPO_ROOT, "agents/openclaw/mcporter-runtime"), runtime, { recursive: true, }); + fs.mkdirSync(path.join(targetRoot, "ci"), { recursive: true }); + fs.mkdirSync(path.join(targetRoot, "scripts", "lib"), { recursive: true }); + fs.writeFileSync(path.join(targetRoot, "ci", "reviewed-npm-audit.json"), "{}\n"); + fs.writeFileSync( + path.join(targetRoot, "scripts", "audit-reviewed-npm-graph.mts"), + "throw new Error('candidate producer executed');\n", + ); + fs.writeFileSync( + path.join(targetRoot, "scripts", "lib", "npm-audit-receipt.mts"), + "throw new Error('candidate verifier executed');\n", + ); fs.copyFileSync(path.join(REPO_ROOT, "ci/npm-audit-exceptions.json"), exceptionFile); fs.copyFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), auditConfigFile); fs.writeFileSync( diff --git a/test/e2e/support/managed-image-protected-runtime-workflow.test.ts b/test/e2e/support/managed-image-protected-runtime-workflow.test.ts index 865d15de9f3..072a7ed9bd4 100644 --- a/test/e2e/support/managed-image-protected-runtime-workflow.test.ts +++ b/test/e2e/support/managed-image-protected-runtime-workflow.test.ts @@ -348,7 +348,31 @@ describe("protected managed-image runtime workflow", () => { runtimeJob(value).needs = ["generate-matrix"]; expect(validateManagedImageProtectedRuntimeWorkflow(value)).toContain( - "managed-image-protected-runtime must depend on base-image-publication, generate-matrix, and managed-image-multiarch-startup", + "managed-image-protected-runtime must depend on base-image-publication, generate-matrix, managed-image-multiarch-startup, and managed-image-protected-audit", + ); + }); + + it("keeps protected audit production in trusted workflow code", () => { + const value = workflow(); + const audit = namedJobStep( + value, + "managed-image-protected-audit", + "Audit exact candidate mcporter graph from trusted code", + ); + audit.uses = "./.candidate-audit/.github/actions/ci-reviewed-npm-audit"; + + expect(validateManagedImageProtectedRuntimeWorkflow(value)).toContain( + "managed-image-protected-audit must execute the trusted reviewed npm audit action", + ); + }); + + it("requires the trusted audit artifact at the protected consumer", () => { + const value = workflow(); + const download = namedStep(value, "Download trusted protected mcporter audit evidence"); + (download.with as Record).path = ".candidate-runtime/reviewed-npm-audit"; + + expect(validateManagedImageProtectedRuntimeWorkflow(value)).toContain( + "managed-image-protected-runtime audit evidence download must bind path to ${{ runner.temp }}/protected-reviewed-npm-audit", ); }); diff --git a/test/inference/managed/managed-image-publication-workflow.test.ts b/test/inference/managed/managed-image-publication-workflow.test.ts index eaac0910f02..ae79bf4c020 100644 --- a/test/inference/managed/managed-image-publication-workflow.test.ts +++ b/test/inference/managed/managed-image-publication-workflow.test.ts @@ -94,6 +94,7 @@ describe("complete managed-image publication workflow", () => { ); expect(action.inputs).toMatchObject({ "cache-directory": { required: true }, + "locked-graph": { default: "", required: false }, "trusted-cache-write": { default: "false", required: false }, }); expect(restores).toHaveLength(2); @@ -129,6 +130,7 @@ describe("complete managed-image publication workflow", () => { ).env, ).toMatchObject({ NEMOCLAW_REVIEWED_NPM_AUDIT_CACHE_DIR: "${{ inputs.cache-directory }}", + NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH: "${{ inputs.locked-graph }}", NPM_CONFIG_USERCONFIG: "/dev/null", }); diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index a72cac16991..20bfaa5e009 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -28,10 +28,9 @@ const DIGEST = "b".repeat(64); let testRoot = ""; let stubBin = ""; let dockerLog = ""; -let dockerAuditBuildCount = ""; -let dockerAuditBuildFailureMode = ""; let dockerBuildCount = ""; let dockerBuildFailureMode = ""; +let receiptVerifyStatus = ""; let seedLog = ""; let registryCurlExit = ""; let registryLog = ""; @@ -53,32 +52,6 @@ printf '%s\n' "$*" >>"$NEMOCLAW_TEST_DOCKER_LOG" case "$*" in "buildx imagetools inspect "*) printf '{}\n' ;; "buildx build "*) - if [[ "$*" == *"--target protected-mcporter-audit-evidence"* ]]; then - output_spec="" - previous="" - for argument in "$@"; do - if [[ "$previous" == "--output" ]]; then output_spec="$argument"; fi - previous="$argument" - done - destination="\${output_spec#type=local,dest=}" - [[ -n "$destination" && "$destination" != "$output_spec" ]] - mkdir -p "$destination" - audit_build_count=0 - if [[ -f "$NEMOCLAW_TEST_DOCKER_AUDIT_BUILD_COUNT" ]]; then - read -r audit_build_count <"$NEMOCLAW_TEST_DOCKER_AUDIT_BUILD_COUNT" - fi - audit_build_count=$((audit_build_count + 1)) - printf '%s\n' "$audit_build_count" >"$NEMOCLAW_TEST_DOCKER_AUDIT_BUILD_COUNT" - if [[ "$NEMOCLAW_TEST_DOCKER_AUDIT_BUILD_FAILURE_MODE:$audit_build_count" == "exact-once:1" ]]; then - printf 'partial\n' >"$destination/partial" - printf '%s\n' 'ERROR: failed to build: failed to solve: stream error: stream ID 71; INTERNAL_ERROR; received from peer' >&2 - exit 42 - fi - [[ ! -e "$destination/partial" ]] - printf '{"result":"pass"}\n' >"$destination/mcporter-runtime.receipt.json" - printf '{"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n' >"$destination/mcporter-runtime.raw.json" - exit 0 - fi build_count=0 if [[ -f "$NEMOCLAW_TEST_DOCKER_BUILD_COUNT" ]]; then read -r build_count <"$NEMOCLAW_TEST_DOCKER_BUILD_COUNT" @@ -129,6 +102,9 @@ esac `#!/usr/bin/env bash set -euo pipefail printf '%s\n' "$*" >>"$NEMOCLAW_TEST_SEED_LOG" +if [[ "$*" == *"/scripts/lib/npm-audit-receipt.mts"* ]]; then + exit "$NEMOCLAW_TEST_RECEIPT_VERIFY_STATUS" +fi mode="$4" shift 4 output="" @@ -184,14 +160,12 @@ function completeImportedCache(cacheRoot: string): void { ); mkdirSync(path.join(cacheRoot, "messaging-npm-cache-seed")); writeFileSync(path.join(cacheRoot, "messaging-npm-cache-seed", "manifest.json"), "{}\n", "utf8"); - const auditDirectory = path.join(cacheRoot, "reviewed-npm-audit"); - mkdirSync(auditDirectory); +} + +function completeAuditEvidence(auditDirectory: string): void { + mkdirSync(auditDirectory, { recursive: true }); writeFileSync(path.join(auditDirectory, "mcporter-runtime.receipt.json"), '{"result":"pass"}\n'); - writeFileSync( - path.join(auditDirectory, "mcporter-runtime.raw.json"), - '{"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n', - ); - writeFileSync(path.join(auditDirectory, "mcporter-runtime.receipt.sha256"), `${DIGEST}\n`); + writeFileSync(path.join(auditDirectory, "mcporter-runtime.raw.json"), '{"metadata":{}}\n'); } function completeSourceBoundary(sourceRoot: string): void { @@ -246,16 +220,6 @@ function recordedBuildInvocations(): string[] { ); } -function recordedAuditBuildInvocations(): string[] { - return readFileSync(dockerLog, "utf8") - .split("\n") - .filter( - (line) => - line.startsWith("buildx build ") && - line.includes("--target protected-mcporter-audit-evidence"), - ); -} - function recordedBuildInvocation(agent: string): string { const invocation = recordedBuildInvocations().find((line) => line.includes(`io.nvidia.nemoclaw.agent=${agent}`), @@ -293,8 +257,6 @@ function runBuild(sourceRoot: string, extraArgs: readonly string[] = [], platfor encoding: "utf8", env: { ...process.env, - NEMOCLAW_TEST_DOCKER_AUDIT_BUILD_COUNT: dockerAuditBuildCount, - NEMOCLAW_TEST_DOCKER_AUDIT_BUILD_FAILURE_MODE: dockerAuditBuildFailureMode, NEMOCLAW_TEST_DOCKER_BUILD_COUNT: dockerBuildCount, NEMOCLAW_TEST_DOCKER_BUILD_FAILURE_MODE: dockerBuildFailureMode, NEMOCLAW_TEST_DOCKER_LOG: dockerLog, @@ -302,6 +264,7 @@ function runBuild(sourceRoot: string, extraArgs: readonly string[] = [], platfor NEMOCLAW_TEST_REGISTRY_LOG: registryLog, NEMOCLAW_TEST_REGISTRY_STATUS: registryStatus, NEMOCLAW_TEST_REAL_PATH: process.env.PATH ?? "", + NEMOCLAW_TEST_RECEIPT_VERIFY_STATUS: receiptVerifyStatus, NEMOCLAW_TEST_SEED_LOG: seedLog, NEMOCLAW_TEST_TEE_FAILURE_MODE: teeFailureMode, PATH: `${stubBin}:${process.env.PATH ?? ""}`, @@ -315,10 +278,9 @@ beforeEach(() => { testRoot = mkdtempSync(path.join(os.tmpdir(), "nemoclaw-protected-build-")); stubBin = path.join(testRoot, "bin"); dockerLog = path.join(testRoot, "docker.log"); - dockerAuditBuildCount = path.join(testRoot, "docker-audit-build-count"); - dockerAuditBuildFailureMode = ""; dockerBuildCount = path.join(testRoot, "docker-build-count"); dockerBuildFailureMode = ""; + receiptVerifyStatus = "0"; seedLog = path.join(testRoot, "seed.log"); registryCurlExit = "0"; registryLog = path.join(testRoot, "registry.log"); @@ -396,7 +358,6 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocations()).toHaveLength(3); - expect(recordedAuditBuildInvocations()).toEqual([]); expect({ openclaw: recordedBuildInvocation("openclaw"), @@ -439,15 +400,7 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(existsSync(cacheRoot)).toBe(true); expect(recordedBuildInvocations()).toHaveLength(3); - expect(recordedAuditBuildInvocations()).toEqual([ - expect.stringContaining( - `--target protected-mcporter-audit-evidence --output type=local,dest=${realpathSync(cacheRoot)}/reviewed-npm-audit`, - ), - ]); - expect(recordedAuditBuildInvocations()[0]).toContain( - `--file ${REPO_ROOT}/Dockerfile.protected-npm-audit`, - ); - expect(recordedAuditBuildInvocations()[0]).not.toContain("--network none"); + expect(existsSync(path.join(cacheRoot, "reviewed-npm-audit"))).toBe(false); expect({ openclaw: recordedBuildInvocation("openclaw"), @@ -481,21 +434,7 @@ describe("protected managed-image build-cache boundary", () => { expect(existsSync(path.join(cacheRoot, "messaging-npm-cache-seed", "manifest.json"))).toBe( true, ); - expect( - readFileSync( - path.join(cacheRoot, "reviewed-npm-audit", "mcporter-runtime.receipt.sha256"), - "utf8", - ), - ).toBe(`${DIGEST}\n`); - expect(recordedBuildInvocation("openclaw")).toContain( - `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(cacheRoot)}/reviewed-npm-audit/mcporter-runtime.receipt.json`, - ); - expect(recordedBuildInvocation("openclaw")).toContain( - `--secret id=nemoclaw-mcporter-audit-raw-report,src=${realpathSync(cacheRoot)}/reviewed-npm-audit/mcporter-runtime.raw.json`, - ); - expect(recordedBuildInvocation("openclaw")).toContain( - `--build-arg NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=${DIGEST}`, - ); + expect(recordedBuildInvocation("openclaw")).not.toContain("nemoclaw-mcporter-audit"); expect(recordedBuildInvocation("hermes")).not.toContain("nemoclaw-mcporter-audit"); expect(recordedBuildInvocation("langchain-deepagents-code")).not.toContain( "nemoclaw-mcporter-audit", @@ -518,23 +457,6 @@ describe("protected managed-image build-cache boundary", () => { expect(retried.status, retried.stderr).toBe(0); }); - it("retries a transient reviewed audit build from clean evidence", () => { - const cacheRoot = path.join(testRoot, "export-cache"); - stubBuildInvocation(); - dockerAuditBuildFailureMode = "exact-once"; - - const result = runBuild(REPO_ROOT, ["--cache-to", cacheRoot]); - const output = `${result.stdout}${result.stderr}`; - - expect(result.status, output).toBe(0); - expect(recordedAuditBuildInvocations()).toHaveLength(2); - expect(existsSync(path.join(cacheRoot, "reviewed-npm-audit", "partial"))).toBe(false); - expect(output).toContain( - "outcome=transient-external agent=reviewed-npm-audit attempt=1/2 retry-in=2s failure=buildkit-http2-internal-error", - ); - expect(output).toContain("outcome=passed-after-retry agent=reviewed-npm-audit attempt=2/2"); - }); - it.each([ ["relative", () => "export-cache"], [ @@ -612,70 +534,48 @@ describe("protected managed-image build-cache boundary", () => { expect(existsSync(dockerLog)).toBe(false); }); - it.each([ - [ - "missing", - (cacheRoot: string) => - rmSync(path.join(cacheRoot, "reviewed-npm-audit"), { recursive: true }), - "reviewed audit evidence is missing or unsafe", - ], - [ - "changed", - (cacheRoot: string) => - writeFileSync( - path.join(cacheRoot, "reviewed-npm-audit", "mcporter-runtime.receipt.sha256"), - `${"c".repeat(64)}\n`, - ), - "reviewed audit receipt hash does not match", - ], - [ - "empty", - (cacheRoot: string) => - writeFileSync( - path.join(cacheRoot, "reviewed-npm-audit", "mcporter-runtime.receipt.sha256"), - "", - ), - "reviewed audit receipt hash does not match", - ], - ])( - "rejects %s reviewed audit evidence before invoking Docker (#11088)", - (_case, mutate, error) => { - const cacheRoot = path.join(testRoot, "imported-cache"); - completeImportedCache(cacheRoot); - stubBuildInvocation(); - mutate(cacheRoot); - - const result = runBuild(REPO_ROOT, ["--cache-from", cacheRoot]); - - expect(result.status, result.stderr).toBe(1); - expect(result.stderr).toContain(error); - expect(existsSync(dockerLog)).toBe(false); - }, - ); - - it("rejects a changed imported audit receipt before invoking Docker (#11088)", () => { + it("rejects incomplete reviewed audit evidence before invoking Docker (#11088)", () => { const cacheRoot = path.join(testRoot, "imported-cache"); + const auditRoot = path.join(testRoot, "audit-evidence"); completeImportedCache(cacheRoot); + mkdirSync(auditRoot); + writeFileSync(path.join(auditRoot, "mcporter-runtime.receipt.json"), "", "utf8"); stubBuildInvocation(); - writeExecutable( - "sha256sum", - `#!/usr/bin/env bash -if [[ "$(<"$1")" == '{"result":"pass"}' ]]; then - printf '%s %s\\n' '${DIGEST}' "$1" -else - printf '%s %s\\n' '${"c".repeat(64)}' "$1" -fi -`, - ); - writeFileSync( - path.join(cacheRoot, "reviewed-npm-audit", "mcporter-runtime.receipt.json"), - '{"result":"changed"}\n', - ); - const result = runBuild(REPO_ROOT, ["--cache-from", cacheRoot]); + const result = runBuild(REPO_ROOT, [ + "--cache-from", + cacheRoot, + "--audit-evidence-from", + auditRoot, + ]); expect(result.status, result.stderr).toBe(1); - expect(result.stderr).toContain("reviewed audit receipt hash does not match"); + expect(result.stderr).toContain("reviewed audit evidence is incomplete"); + expect(existsSync(dockerLog)).toBe(false); + }); + + it("binds external evidence to the trusted verifier and candidate graph (#11088)", () => { + const cacheRoot = path.join(testRoot, "imported-cache"); + const auditRoot = path.join(testRoot, "audit-evidence"); + completeImportedCache(cacheRoot); + completeAuditEvidence(auditRoot); + stubBuildInvocation(); + receiptVerifyStatus = "42"; + + const result = runBuild(REPO_ROOT, [ + "--cache-from", + cacheRoot, + "--audit-evidence-from", + auditRoot, + ]); + const verification = readFileSync(seedLog, "utf8"); + + expect(result.status, result.stderr).toBe(42); + expect(verification).toContain(`${REPO_ROOT}/scripts/lib/npm-audit-receipt.mts`); + expect(verification).toContain( + `--package-json ${REPO_ROOT}/agents/openclaw/mcporter-runtime/package.json`, + ); + expect(verification).toContain(`--audit-config ${REPO_ROOT}/ci/reviewed-npm-audit.json`); expect(existsSync(dockerLog)).toBe(false); }); @@ -693,10 +593,17 @@ fi const originalSeedNames = readdirSync(sourceSeed).sort(); const originalMcpSeedNames = readdirSync(sourceMcpSeed).sort(); const originalMessagingSeedNames = readdirSync(sourceMessagingSeed).sort(); + const auditRoot = path.join(testRoot, "audit-evidence"); completeImportedCache(cacheRoot); + completeAuditEvidence(auditRoot); stubBuildInvocation(); - const result = runBuild(REPO_ROOT, ["--cache-from", cacheRoot]); + const result = runBuild(REPO_ROOT, [ + "--cache-from", + cacheRoot, + "--audit-evidence-from", + auditRoot, + ]); expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocations()).toHaveLength(3); @@ -723,7 +630,7 @@ fi }); expect(recordedBuildInvocation("openclaw").split(" ")).toContain("--no-cache"); expect(recordedBuildInvocation("openclaw")).toContain( - `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(cacheRoot)}/reviewed-npm-audit/mcporter-runtime.receipt.json`, + `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(auditRoot)}/mcporter-runtime.receipt.json`, ); expect(recordedBuildInvocation("openclaw")).toContain( `--build-arg NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=${DIGEST}`, diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index c5fc66b396d..e19467df21f 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -241,19 +241,24 @@ describe("mcporter image supply-chain controls", () => { it("carries a networked reviewed audit into the offline protected OpenClaw build", () => { const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); - const producer = fs.readFileSync(path.join(repoRoot, "Dockerfile.protected-npm-audit"), "utf8"); - const flattenedProducer = producer.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + const workflow = fs.readFileSync(path.join(repoRoot, ".github/workflows/e2e.yaml"), "utf8"); + const protectedController = fs.readFileSync( + path.join(repoRoot, "scripts/checks/build-protected-managed-images.sh"), + "utf8", + ); const installStart = contents.indexOf("# Upgrade stale bases."); const installEnd = contents.indexOf("# Patch OpenClaw media fetch", installStart); const protectedInstall = contents.slice(installStart, installEnd); - expect(producer).toContain( - `FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c AS protected-mcporter-audit`, + expect(workflow).toContain("uses: ./.github/actions/ci-reviewed-npm-audit"); + expect(workflow).toContain("locked-graph: mcporter-runtime"); + expect(workflow).toContain('--audit-evidence-from "$RUNNER_TEMP/protected-reviewed-npm-audit"'); + expect(protectedController).toContain( + 'trusted_receipt_verifier="$controller_root/scripts/lib/npm-audit-receipt.mts"', ); - expect(flattenedProducer).toContain( - "NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH=mcporter-runtime NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR=artifacts/reviewed-npm-audit", + expect(protectedController).toContain( + '--package-json "$source_root/agents/openclaw/mcporter-runtime/package.json"', ); - expect(producer).toContain("FROM scratch AS protected-mcporter-audit-evidence"); expect(contents).toContain( "COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh", ); @@ -261,9 +266,6 @@ describe("mcporter image supply-chain controls", () => { "--mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false", ); expect(contents).not.toContain("from=protected-mcporter-audit-cache"); - expect(mcporterAuditHelper).toContain( - "seed=/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", - ); expect(mcporterAuditHelper).toContain( "cached mcporter audit requires paired receipt, raw report, and receipt SHA-256", ); diff --git a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts index 2398e37e5d4..18134763a57 100644 --- a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts +++ b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts @@ -13,8 +13,11 @@ type WorkflowStep = WorkflowRecord & { }; const JOB_ID = "managed-image-protected-runtime"; +const AUDIT_JOB_ID = "managed-image-protected-audit"; const SELECTOR = - "${{ always() && github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && needs['base-image-publication'].result == 'success' && needs['generate-matrix'].result == 'success' && needs['managed-image-multiarch-startup'].result == 'success' && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }}"; + "${{ always() && github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && needs['base-image-publication'].result == 'success' && needs['generate-matrix'].result == 'success' && needs['managed-image-multiarch-startup'].result == 'success' && needs['managed-image-protected-audit'].result == 'success' && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }}"; +const AUDIT_SELECTOR = + "${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }}"; const ACTIVATION_PATH = "ci/protected-managed-image-runtime-activation-v1.json"; const LIVE_TEST_PATH = "test/e2e/live/managed-image-protected-runtime.test.ts"; const REGISTRY_IMAGE = @@ -91,6 +94,77 @@ function requireOrderedSteps( export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowRecord): string[] { const errors: string[] = []; + const auditJob = record(record(workflow.jobs)[AUDIT_JOB_ID]); + if (Object.keys(auditJob).length === 0) { + errors.push(`workflow missing ${AUDIT_JOB_ID} job`); + } else { + if (auditJob.needs !== "generate-matrix") { + errors.push(`${AUDIT_JOB_ID} must depend on generate-matrix`); + } + if (auditJob.if !== AUDIT_SELECTOR) { + errors.push(`${AUDIT_JOB_ID} must use the protected runtime execution plan`); + } + if (auditJob["runs-on"] !== "ubuntu-24.04" || auditJob["timeout-minutes"] !== 25) { + errors.push(`${AUDIT_JOB_ID} must keep its reviewed hosted runner and timeout`); + } + if (!isDeepStrictEqual(auditJob.permissions, { contents: "read" })) { + errors.push(`${AUDIT_JOB_ID} permissions must be exactly contents: read`); + } + const auditSteps = steps(auditJob.steps); + const trustedAuditCheckout = auditSteps.find( + (step) => step.name === "Checkout trusted reviewed npm audit", + ); + const candidateAuditCheckout = auditSteps.find( + (step) => step.name === "Checkout exact protected audit target", + ); + const trustedAudit = auditSteps.find( + (step) => step.name === "Audit exact candidate mcporter graph from trusted code", + ); + const checkoutAction = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"; + if ( + trustedAuditCheckout?.uses !== checkoutAction || + candidateAuditCheckout?.uses !== checkoutAction + ) { + errors.push(`${AUDIT_JOB_ID} must pin both trusted and candidate checkouts`); + } + requireValues(errors, `${AUDIT_JOB_ID} trusted checkout`, record(trustedAuditCheckout?.with), { + repository: "${{ github.repository }}", + ref: "${{ inputs.workflow_sha || github.workflow_sha }}", + "persist-credentials": false, + }); + const trustedSparseCheckout = text(record(trustedAuditCheckout?.with)["sparse-checkout"]); + for (const trustedPath of [ + ".github/actions/ci-reviewed-npm-audit", + "ci/npm-audit-exceptions.json", + "ci/reviewed-npm-audit.json", + "scripts/audit-reviewed-npm-graph.mts", + "scripts/lib/npm-audit-receipt.mts", + ]) { + if (!trustedSparseCheckout.split("\n").includes(trustedPath)) { + errors.push(`${AUDIT_JOB_ID} trusted checkout must include ${trustedPath}`); + } + } + requireValues( + errors, + `${AUDIT_JOB_ID} candidate checkout`, + record(candidateAuditCheckout?.with), + { + repository: "${{ inputs.checkout_repository || github.repository }}", + ref: "${{ inputs.checkout_sha || github.sha }}", + path: ".candidate-audit", + "persist-credentials": false, + }, + ); + if (trustedAudit?.uses !== "./.github/actions/ci-reviewed-npm-audit") { + errors.push(`${AUDIT_JOB_ID} must execute the trusted reviewed npm audit action`); + } + requireValues(errors, `${AUDIT_JOB_ID} action`, record(trustedAudit?.with), { + "target-root": "${{ github.workspace }}/.candidate-audit", + "report-dir": "artifacts/reviewed-npm-audit", + "cache-directory": "${{ runner.temp }}/reviewed-npm-audit-cache", + "locked-graph": "mcporter-runtime", + }); + } const job = record(record(workflow.jobs)[JOB_ID]); if (Object.keys(job).length === 0) return [`workflow missing ${JOB_ID} job`]; @@ -99,10 +173,11 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR "base-image-publication", "generate-matrix", "managed-image-multiarch-startup", + "managed-image-protected-audit", ]) ) { errors.push( - `${JOB_ID} must depend on base-image-publication, generate-matrix, and managed-image-multiarch-startup`, + `${JOB_ID} must depend on base-image-publication, generate-matrix, managed-image-multiarch-startup, and managed-image-protected-audit`, ); } if (job.if !== SELECTOR) errors.push(`${JOB_ID} must use the trusted execution plan`); @@ -221,6 +296,20 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR name: "${{ env.NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE_ARTIFACT }}", path: "${{ env.NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE }}", }); + const auditDownload = requireStep( + errors, + workflowSteps, + "Download trusted protected mcporter audit evidence", + ); + if ( + auditDownload?.uses !== "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" + ) { + errors.push(`${JOB_ID} must pin the reviewed audit evidence download action`); + } + requireValues(errors, `${JOB_ID} audit evidence download`, record(auditDownload?.with), { + name: "reviewed-npm-audit", + path: "${{ runner.temp }}/protected-reviewed-npm-audit", + }); const buildx = requireStep(errors, workflowSteps, "Set up protected runtime Buildx"); if (buildx?.uses !== "docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c") { @@ -317,14 +406,13 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR "--platform linux/amd64", '--source-root "$GITHUB_WORKSPACE/.candidate-runtime"', '--cache-from "$NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE"', + '--audit-evidence-from "$RUNNER_TEMP/protected-reviewed-npm-audit"', '--openclaw-base "$BASE_OPENCLAW"', '--hermes-base "$BASE_HERMES"', '--dcode-base "$BASE_DCODE"', ]); if ( - text(build?.run).includes( - ".candidate-runtime/scripts/checks/build-protected-managed-images.sh", - ) + text(build?.run).includes(".candidate-runtime/scripts/checks/build-protected-managed-images.sh") ) { errors.push(`${JOB_ID} build controller must execute trusted workflow code`); } @@ -390,6 +478,7 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR "Checkout trusted protected runtime qualification", "Checkout exact protected runtime candidate source", "Download exact protected runtime build cache", + "Download trusted protected mcporter audit evidence", "Prepare E2E workspace", "Validate protected runtime activation contract", "Resolve reviewed Hermes runtime base image", From 1fc8653143fccafb9ca3069f4e213cf36b036e06 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:51:18 -0700 Subject: [PATCH 30/56] docs(audit): describe trusted protected producer --- agents/openclaw/dependency-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index 66f6cb2f416..f31e954b783 100644 --- a/agents/openclaw/dependency-review.md +++ b/agents/openclaw/dependency-review.md @@ -28,7 +28,7 @@ Update it and `agents/openclaw/mcporter-runtime/package*.json` together whenever Both image paths install the committed graph with `npm ci --ignore-scripts --omit=dev` because the published package declares no install-time lifecycle script and NemoClaw needs only its already-built CLI. The reviewed audit wrapper reports lower-severity production findings and blocks unaccepted high or critical advisories. The default `ci/npm-audit-exceptions.json` registry is empty. Any future exception must match one advisory, graph, package, installed version, and severity; identify an owner and NemoClaw tracking issue; state a decision, rationale, and expiry no more than 30 days away; and include compensating controls for temporary risk acceptance. Missing, malformed, expired, overlong, mismatched, or unused exceptions fail closed. The repository-wide audit also rejects exceptions for unknown graph IDs. Registry signature verification remains a separate control. -Managed image publication supplies the mcporter receipt and raw report as BuildKit secrets. Protected qualification produces the same evidence during its networked cache export. Its offline consumer rejects missing or invalid cached evidence before building an image. Other image builds without paired evidence run the reviewed audit directly and fail closed if completeness cannot be established. +Managed image publication supplies the mcporter receipt and raw report as BuildKit secrets. The trusted protected-audit job audits the exact candidate mcporter graph. It publishes the evidence separately from the build cache. The offline consumer rejects missing or invalid evidence before building an image. Other image builds without paired evidence run the reviewed audit directly and fail closed if completeness cannot be established. ## WeChat plugin runtime graph From 897354b06e49fc0443314487cae73bdd5028eea3 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:58:58 -0700 Subject: [PATCH 31/56] refactor(ci): inline audit evidence input --- scripts/checks/build-protected-managed-images.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index 3e4ae422276..8b375f152e7 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -247,8 +247,7 @@ validate_audit_evidence() { } if [[ -n "$cache_from" ]]; then - audit_evidence_dir="$audit_evidence_from" - validate_audit_evidence "$audit_evidence_dir" + validate_audit_evidence "$audit_evidence_from" fi if [[ -n "$cache_from" ]]; then From cb44beb437abdb68d7f5fbc6832f4a16f18c1d7f Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:11:00 -0700 Subject: [PATCH 32/56] fix(ci): remove stale audit state --- scripts/checks/build-protected-managed-images.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index 8b375f152e7..92f67fe0e91 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -216,7 +216,6 @@ trap restore_worktree EXIT trap 'exit 130' INT trap 'exit 143' TERM -audit_evidence_dir="" audit_receipt="" audit_raw_report="" audit_receipt_sha256="" From 116d974626dc2c70e5f2dfb377d889f784b336b5 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 9 Sep 2026 07:55:11 -0400 Subject: [PATCH 33/56] fix(ci): align protected audit workflow gates Signed-off-by: Julie Yaunches --- .github/workflows/e2e.yaml | 5 +-- ...nim-flow-managed-llama-cpp-profile.test.ts | 12 ++++++- .../releases/reviewed-npm-audit.test.ts | 6 ++-- test/security/mcporter-supply-chain.test.ts | 36 ------------------- tools/e2e/operations-workflow-boundary.mts | 6 ++++ 5 files changed, 24 insertions(+), 41 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 132778695ae..c883904a004 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -5089,14 +5089,14 @@ jobs: name: ${{ env.NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE_ARTIFACT }} path: ${{ env.NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE }} + - *dockerhub-auth + - name: Download trusted protected mcporter audit evidence uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: reviewed-npm-audit path: ${{ runner.temp }}/protected-reviewed-npm-audit - - *dockerhub-auth - - name: Set up protected runtime Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 with: @@ -6124,6 +6124,7 @@ jobs: openshell-credential-generation-window, openshell-dev-artifact, mcp-bridge-dev, + managed-image-protected-audit, managed-image-multiarch-startup, llama-cpp-dgx-spark-plan, llama-cpp-dgx-spark-qualification, diff --git a/src/lib/onboard/setup-nim-flow-managed-llama-cpp-profile.test.ts b/src/lib/onboard/setup-nim-flow-managed-llama-cpp-profile.test.ts index 84f80d64eb2..cabdd6c06b9 100644 --- a/src/lib/onboard/setup-nim-flow-managed-llama-cpp-profile.test.ts +++ b/src/lib/onboard/setup-nim-flow-managed-llama-cpp-profile.test.ts @@ -529,11 +529,21 @@ describe("managed llama.cpp profile onboarding", () => { vi.stubEnv("NEMOCLAW_LLAMACPP_RECIPE", "llama-cpp.qwen3-6-35b-a3b.n1x-wsl.v1"); vi.stubEnv("DOCKER_CONTEXT", "remote-builder"); const installManagedLlamaCpp = vi.fn(); + const discoverManagedLlamaCppSelections = vi.fn( + (env, gpu, catalog, _collectionOptions, selectionOptions) => + discoverManagedLlamaCppSelectionsForGpu( + env, + gpu, + catalog, + n1xCollectionOptions(), + selectionOptions, + ), + ); const setupNim = createSetupNim( makeDeps({ isNonInteractive: () => true, getNonInteractiveProvider: () => "install-llama-cpp", - discoverManagedLlamaCppSelections: discoverManagedLlamaCppSelectionsForGpu, + discoverManagedLlamaCppSelections, installManagedLlamaCpp, }), ); diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index 9c5394b5db4..bb759ce48a4 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -337,8 +337,10 @@ describe("reviewed npm audit gate", () => { path.join(REPO_ROOT, ".github", "workflows"), ); - expect(callers).toHaveLength(5); - expect(callers.map(({ timeoutMinutes }) => timeoutMinutes)).toEqual([25, 25, 25, 25, 25]); + expect(callers).toHaveLength(6); + expect(callers.map(({ timeoutMinutes }) => timeoutMinutes)).toEqual([ + 25, 25, 25, 25, 25, 25, + ]); expect(Math.min(...callers.map(({ timeoutMinutes }) => timeoutMinutes))).toBeGreaterThanOrEqual( minimumJobTimeoutMinutes, ); diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index e19467df21f..d1446d6afb2 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -239,42 +239,6 @@ describe("mcporter image supply-chain controls", () => { expect(contents).toContain("StreamableHTTPServerTransport"); }); - it("carries a networked reviewed audit into the offline protected OpenClaw build", () => { - const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); - const workflow = fs.readFileSync(path.join(repoRoot, ".github/workflows/e2e.yaml"), "utf8"); - const protectedController = fs.readFileSync( - path.join(repoRoot, "scripts/checks/build-protected-managed-images.sh"), - "utf8", - ); - const installStart = contents.indexOf("# Upgrade stale bases."); - const installEnd = contents.indexOf("# Patch OpenClaw media fetch", installStart); - const protectedInstall = contents.slice(installStart, installEnd); - - expect(workflow).toContain("uses: ./.github/actions/ci-reviewed-npm-audit"); - expect(workflow).toContain("locked-graph: mcporter-runtime"); - expect(workflow).toContain('--audit-evidence-from "$RUNNER_TEMP/protected-reviewed-npm-audit"'); - expect(protectedController).toContain( - 'trusted_receipt_verifier="$controller_root/scripts/lib/npm-audit-receipt.mts"', - ); - expect(protectedController).toContain( - '--package-json "$source_root/agents/openclaw/mcporter-runtime/package.json"', - ); - expect(contents).toContain( - "COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh", - ); - expect(contents).toContain( - "--mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false", - ); - expect(contents).not.toContain("from=protected-mcporter-audit-cache"); - expect(mcporterAuditHelper).toContain( - "cached mcporter audit requires paired receipt, raw report, and receipt SHA-256", - ); - expect(mcporterAuditHelper).toContain("build-context mcporter audit evidence is not trusted"); - expect(installStart).toBeGreaterThanOrEqual(0); - expect(installEnd).toBeGreaterThan(installStart); - expect(protectedInstall).not.toMatch(/RUN --network=(?:default|host)/); - }); - it("copies the cached base-image audit report only after receipt verification succeeds", () => { const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile.base"), "utf8"); const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); diff --git a/tools/e2e/operations-workflow-boundary.mts b/tools/e2e/operations-workflow-boundary.mts index c24d0c22dbb..e6121baea4a 100644 --- a/tools/e2e/operations-workflow-boundary.mts +++ b/tools/e2e/operations-workflow-boundary.mts @@ -600,6 +600,11 @@ function validateManualPrDispatch(errors: string[], workflow: OperationsWorkflow step.name === "Checkout trusted protected runtime qualification" && step.with?.repository === "${{ github.repository }}" && step.with?.ref === "${{ inputs.workflow_sha || github.workflow_sha }}"; + const trustedManagedImageAuditCheckout = + jobName === "managed-image-protected-audit" && + step.name === "Checkout trusted reviewed npm audit" && + step.with?.repository === "${{ github.repository }}" && + step.with?.ref === "${{ inputs.workflow_sha || github.workflow_sha }}"; const trustedManagedImageMultiarchResolverCheckout = jobName === "managed-image-multiarch-startup" && step.name === "Checkout trusted Hermes resolver" && @@ -672,6 +677,7 @@ function validateManualPrDispatch(errors: string[], workflow: OperationsWorkflow trustedPublicationCheckout || trustedOpenShellSdkPackageCheckout || trustedManagedImageMultiarchResolverCheckout || + trustedManagedImageAuditCheckout || trustedManagedImageRuntimeCheckout || trustedLlamaCppPlanCheckout || trustedLlamaCppQualificationCheckout || From ab18eac39484b5fd266deb0d3b971e0ea0ce28a5 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 9 Sep 2026 19:12:43 +0700 Subject: [PATCH 34/56] fix(ci): reuse audit evidence before protected builds Reuse the existing trusted audit action and cache before the protected offline build. Remove the separate producer job, graph selector, and unused retry cleanup argument. Keep receipt validation, completeness checks, and the existing retry budget. Signed-off-by: San Dang --- .../actions/ci-reviewed-npm-audit/action.yaml | 5 - .github/workflows/e2e.yaml | 68 ++---------- agents/openclaw/dependency-review.md | 2 +- scripts/audit-reviewed-npm-graph.mts | 51 --------- .../checks/build-protected-managed-images.sh | 9 +- .../reviewed-npm-audit-handoff.test.ts | 78 +++++--------- .../releases/reviewed-npm-audit.test.ts | 22 +--- ...d-image-protected-runtime-workflow.test.ts | 22 ++-- ...managed-image-publication-workflow.test.ts | 2 - test/security/mcporter-supply-chain.test.ts | 35 ------ ...ge-protected-runtime-workflow-boundary.mts | 100 +++--------------- tools/e2e/workflow-boundary.mts | 9 +- 12 files changed, 72 insertions(+), 331 deletions(-) diff --git a/.github/actions/ci-reviewed-npm-audit/action.yaml b/.github/actions/ci-reviewed-npm-audit/action.yaml index 512885ffdae..c70ce662068 100644 --- a/.github/actions/ci-reviewed-npm-audit/action.yaml +++ b/.github/actions/ci-reviewed-npm-audit/action.yaml @@ -18,10 +18,6 @@ inputs: description: Whether this trusted caller may publish reusable audit records. required: false default: "false" - locked-graph: - description: Optional configured locked graph to audit instead of every reviewed graph. - required: false - default: "" runs: using: composite @@ -105,7 +101,6 @@ runs: - name: Materialize and audit reviewed npm graphs shell: bash env: - NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH: ${{ inputs.locked-graph }} NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT: ${{ inputs.target-root }} NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR: ${{ inputs.report-dir }} NEMOCLAW_REVIEWED_NPM_AUDIT_CACHE_DIR: ${{ inputs.cache-directory }} diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 132778695ae..db8bf01e780 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -4224,49 +4224,6 @@ jobs: run: bash .github/scripts/docker-auth-cleanup.sh # Manual PR qualification also requires the exact candidate activation contract. - managed-image-protected-audit: - name: Produce trusted protected mcporter audit evidence - needs: generate-matrix - if: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }} - runs-on: ubuntu-24.04 - timeout-minutes: 25 - permissions: - contents: read - steps: - - name: Checkout trusted reviewed npm audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - ref: ${{ inputs.workflow_sha || github.workflow_sha }} - persist-credentials: false - sparse-checkout: | - .github/actions/ci-reviewed-npm-audit - ci/npm-audit-exceptions.json - ci/reviewed-npm-audit.json - scripts/audit-reviewed-npm-graph.mts - scripts/lib/npm-audit-receipt.mts - scripts/lib/repository-input-path.mts - scripts/lib/openclaw-npm-remediation.mts - scripts/lib/reviewed-npm-archive.mts - scripts/lib/reviewed-npm-audit.mts - sparse-checkout-cone-mode: false - - - name: Checkout exact protected audit target - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ inputs.checkout_repository || github.repository }} - ref: ${{ inputs.checkout_sha || github.sha }} - path: .candidate-audit - persist-credentials: false - - - name: Audit exact candidate mcporter graph from trusted code - uses: ./.github/actions/ci-reviewed-npm-audit - with: - target-root: ${{ github.workspace }}/.candidate-audit - report-dir: artifacts/reviewed-npm-audit - cache-directory: ${{ runner.temp }}/reviewed-npm-audit-cache - locked-graph: mcporter-runtime - managed-image-multiarch-startup: name: Protected managed-image startup (${{ matrix.platform }}) needs: [base-image-publication, generate-matrix] @@ -4999,14 +4956,8 @@ jobs: # assertions without the hosted cache. managed-image-protected-runtime: name: Protected managed-image GPU and local inference - needs: - [ - base-image-publication, - generate-matrix, - managed-image-multiarch-startup, - managed-image-protected-audit, - ] - if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && needs['base-image-publication'].result == 'success' && needs['generate-matrix'].result == 'success' && needs['managed-image-multiarch-startup'].result == 'success' && needs['managed-image-protected-audit'].result == 'success' && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }} + needs: [base-image-publication, generate-matrix, managed-image-multiarch-startup] + if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && needs['base-image-publication'].result == 'success' && needs['generate-matrix'].result == 'success' && needs['managed-image-multiarch-startup'].result == 'success' && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }} runs-on: linux-amd64-gpu-rtxpro6000-latest-1 timeout-minutes: 300 permissions: @@ -5083,18 +5034,19 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Reuse or refresh reviewed audit evidence before the offline build + uses: ./.github/actions/ci-reviewed-npm-audit + with: + target-root: ${{ github.workspace }}/.candidate-runtime + report-dir: artifacts/reviewed-npm-audit + cache-directory: ${{ runner.temp }}/reviewed-npm-audit-cache + - name: Download exact protected runtime build cache uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ env.NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE_ARTIFACT }} path: ${{ env.NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE }} - - name: Download trusted protected mcporter audit evidence - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: reviewed-npm-audit - path: ${{ runner.temp }}/protected-reviewed-npm-audit - - *dockerhub-auth - name: Set up protected runtime Buildx @@ -5246,7 +5198,7 @@ jobs: --platform linux/amd64 \ --source-root "$GITHUB_WORKSPACE/.candidate-runtime" \ --cache-from "$NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE" \ - --audit-evidence-from "$RUNNER_TEMP/protected-reviewed-npm-audit" \ + --audit-evidence-from "$GITHUB_WORKSPACE/.candidate-runtime/artifacts/reviewed-npm-audit" \ --openclaw-base "$BASE_OPENCLAW" \ --hermes-base "$BASE_HERMES" \ --dcode-base "$BASE_DCODE" diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index f31e954b783..02474f77db0 100644 --- a/agents/openclaw/dependency-review.md +++ b/agents/openclaw/dependency-review.md @@ -28,7 +28,7 @@ Update it and `agents/openclaw/mcporter-runtime/package*.json` together whenever Both image paths install the committed graph with `npm ci --ignore-scripts --omit=dev` because the published package declares no install-time lifecycle script and NemoClaw needs only its already-built CLI. The reviewed audit wrapper reports lower-severity production findings and blocks unaccepted high or critical advisories. The default `ci/npm-audit-exceptions.json` registry is empty. Any future exception must match one advisory, graph, package, installed version, and severity; identify an owner and NemoClaw tracking issue; state a decision, rationale, and expiry no more than 30 days away; and include compensating controls for temporary risk acceptance. Missing, malformed, expired, overlong, mismatched, or unused exceptions fail closed. The repository-wide audit also rejects exceptions for unknown graph IDs. Registry signature verification remains a separate control. -Managed image publication supplies the mcporter receipt and raw report as BuildKit secrets. The trusted protected-audit job audits the exact candidate mcporter graph. It publishes the evidence separately from the build cache. The offline consumer rejects missing or invalid evidence before building an image. Other image builds without paired evidence run the reviewed audit directly and fail closed if completeness cannot be established. +Managed image publication supplies the mcporter receipt and raw report as BuildKit secrets. Before its offline build, the protected job runs the existing trusted audit action against the candidate inputs. The action reuses matching, unexpired audit records or refreshes them through the configured registry. The offline consumer verifies the receipt and raw report before building an image. Other image builds without paired evidence run the reviewed audit directly and fail closed if completeness cannot be established. ## WeChat plugin runtime graph diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index aeb9ccc4beb..80db38ae096 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -73,16 +73,6 @@ type ReviewedAuditReport = Readonly<{ threshold?: Severity; }>; -export function selectLockedGraph( - config: Readonly<{ lockedGraphs: readonly LockedGraph[] }>, - graphId: string | undefined, -): Readonly<{ graph: LockedGraph; index: number }> | undefined { - if (!graphId) return undefined; - const index = config.lockedGraphs.findIndex((graph) => graph.id === graphId); - if (index < 0) throw new Error("reviewed npm audit locked graph is not configured"); - return { graph: config.lockedGraphs[index]!, index }; -} - const TRUSTED_REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const TARGET_REPO_ROOT = fs.realpathSync( path.resolve(process.env.NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT ?? TRUSTED_REPO_ROOT), @@ -910,10 +900,6 @@ export function assertReviewedAuditReportsPass( function main(): void { const config = readConfig(); - const selectedLockedGraph = selectLockedGraph( - config, - process.env.NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH, - ); const expectedNode = `v${config.nodeVersion}`; if (process.version !== expectedNode) { throw new Error(`reviewed npm audit requires Node ${expectedNode}; running ${process.version}`); @@ -942,43 +928,6 @@ function main(): void { } const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-audit-")); try { - if (selectedLockedGraph) { - const { graph, index } = selectedLockedGraph; - const result = auditLockedGraph( - graph, - index, - config, - tempRoot, - exceptionFile, - artifactDirectory, - npmVersion, - ); - assertReviewedAuditReportsPass( - [{ label: graph.label, threshold: graph.severityThreshold, result }], - config.severityThreshold, - ); - emitAuditReceipt({ - artifactDirectory, - graphId: graph.id, - npmVersion, - packageJsonFile: targetRepositoryPath( - path.join(graph.directory, "package.json"), - `${graph.label} package manifest`, - ), - packageLockFile: targetRepositoryPath( - path.join(graph.directory, "package-lock.json"), - `${graph.label} lockfile`, - ), - rawReportFile: path.join( - artifactDirectory, - `locked-graph-${index + 1}.json`, - ), - registryOrigin: NPM_AUDIT_REGISTRY, - result, - threshold: graph.severityThreshold ?? config.severityThreshold, - }); - return; - } const sourceResult = auditSourceGraph( config, tempRoot, diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index 92f67fe0e91..8474abe6742 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -334,8 +334,7 @@ confirm_build_retry_state() { run_build_with_retry() { local agent="$1" local image_repository="$2" - local retry_cleanup="$3" - shift 3 + shift 2 local -a build_command=("$@") local attempt_log="$work_dir/${agent}-build-attempt.log" local max_attempts=2 @@ -383,9 +382,7 @@ run_build_with_retry() { return "$build_status" fi - if [[ -n "$retry_cleanup" ]]; then - rm -rf -- "$retry_cleanup" - elif ! confirm_build_retry_state "$agent" "$image_repository"; then + if ! confirm_build_retry_state "$agent" "$image_repository"; then echo "::error::Protected managed-image build outcome=failed-no-retry agent=${agent} attempt=${attempt}/${max_attempts} failure=state-check" >&2 return "$build_status" fi @@ -471,7 +468,7 @@ build_agent() { --build-arg "NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root" --build-arg "TARGETARCH=${target_arch}" "$source_root") - run_build_with_retry "$agent" "$image_repository" "" "${build_command[@]}" + run_build_with_retry "$agent" "$image_repository" "${build_command[@]}" local digest digest="$(jq -er '."containerimage.digest"' "$metadata")" diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 0a5edf2c062..cf5a7604c6f 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -9,6 +9,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import YAML from "yaml"; +import { emitAuditReceipt } from "../../../scripts/audit-reviewed-npm-graph.mts"; const REPO_ROOT = path.join(import.meta.dirname, "../../.."); const TRUSTED_WORKFLOWS = [ @@ -97,22 +98,17 @@ describe("reviewed npm audit handoff", () => { const targetRoot = path.join(root, "target"); const runtime = path.join(targetRoot, "agents/openclaw/mcporter-runtime"); const artifactDirectory = path.join(targetRoot, "artifacts/reviewed-npm-audit"); - const producerBin = path.join(root, "producer-bin"); const exceptionFile = path.join(trustedRoot, "ci/npm-audit-exceptions.json"); const auditConfigFile = path.join(trustedRoot, "ci/reviewed-npm-audit.json"); const auditConfig = JSON.parse( fs.readFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), ); const npmVersion = auditConfig.npmVersion as string; - const reviewedMcporter = auditConfig.lockedGraphs.find( - ({ id }: { id: string }) => id === "mcporter-runtime", - ); const rawReport = '{"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n'; try { fs.mkdirSync(runtime, { recursive: true }); fs.mkdirSync(path.join(trustedRoot, "ci"), { recursive: true }); - fs.mkdirSync(producerBin); fs.cpSync(path.join(REPO_ROOT, "scripts"), path.join(trustedRoot, "scripts"), { recursive: true, }); @@ -132,55 +128,35 @@ describe("reviewed npm audit handoff", () => { ); fs.copyFileSync(path.join(REPO_ROOT, "ci/npm-audit-exceptions.json"), exceptionFile); fs.copyFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), auditConfigFile); + fs.mkdirSync(artifactDirectory, { recursive: true }); + const rawReportFile = path.join(artifactDirectory, "audit.json"); + fs.writeFileSync(rawReportFile, rawReport); fs.writeFileSync( - path.join(producerBin, "npm"), - `#!/usr/bin/env node -const fs = require("node:fs"); -const args = process.argv.slice(2); -if (args[0] === "--version") console.log(process.env.NEMOCLAW_TEST_NPM_VERSION); -else if (args[0] === "config") console.log("https://registry.npmjs.org/"); -else if (args[0] === "view") console.log(args.includes("dist.tarball") ? process.env.NEMOCLAW_TEST_TARBALL : process.env.NEMOCLAW_TEST_INTEGRITY); -else if (args[0] === "audit" && args[1] !== "signatures") process.stdout.write(process.env.NEMOCLAW_TEST_AUDIT_OUTPUT); -else if (args[0] === "ci") { - const lock = JSON.parse(fs.readFileSync("package-lock.json", "utf8")); - for (const [location, entry] of Object.entries(lock.packages)) { - if (!location) continue; - fs.mkdirSync(location, { recursive: true }); - fs.writeFileSync(location + "/package.json", JSON.stringify({ - name: location.slice(location.lastIndexOf("node_modules/") + 13), - version: entry.version, - dependencies: entry.dependencies, - peerDependencies: entry.peerDependencies, - peerDependenciesMeta: entry.peerDependenciesMeta, - })); - } -} -`, - { mode: 0o755 }, + path.join(artifactDirectory, "audit.provenance.json"), + JSON.stringify({ run: { startedAt: new Date().toISOString() } }), ); - const producer = spawnSync( - process.execPath, - [ - "--experimental-strip-types", - path.join(trustedRoot, "scripts/audit-reviewed-npm-graph.mts"), - ], - { - cwd: trustedRoot, - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH: "mcporter-runtime", - NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR: "artifacts/reviewed-npm-audit", - NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT: targetRoot, - NEMOCLAW_TEST_AUDIT_OUTPUT: rawReport, - NEMOCLAW_TEST_INTEGRITY: reviewedMcporter.integrity, - NEMOCLAW_TEST_NPM_VERSION: npmVersion, - NEMOCLAW_TEST_TARBALL: reviewedMcporter.tarballUrl, - PATH: `${producerBin}:${process.env.PATH ?? ""}`, - }, + emitAuditReceipt({ + artifactDirectory, + graphId: "mcporter-runtime", + npmVersion, + packageJsonFile: path.join(runtime, "package.json"), + packageLockFile: path.join(runtime, "package-lock.json"), + rawReportFile, + registryOrigin: "https://registry.yarnpkg.com", + result: { + acceptedAdvisories: [], + blockingThreshold: "high", + exceptionPolicySha256: createHash("sha256") + .update(fs.readFileSync(exceptionFile)) + .digest("hex"), + graph: "mcporter-runtime", + reported: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, + schemaVersion: 1, + status: "clean", + unacceptedBlockingAdvisories: [], }, - ); - expect(producer.status, producer.stderr).toBe(0); + threshold: "high", + }); const receiptFile = path.join(artifactDirectory, "mcporter-runtime.receipt.json"); const retainedPackageJson = path.join(runtime, "package.json"); diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index 9c5394b5db4..695df71905f 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -31,10 +31,6 @@ import { vulnerabilityCounts, } from "../../../scripts/lib/reviewed-npm-audit.mts"; import { reviewedNpmAuditWorkflowDeadlines } from "../../helpers/reviewed-npm-audit-workflow"; -import { - parseAuditConfig, - selectLockedGraph, -} from "../../../scripts/audit-reviewed-npm-graph.mts"; const REPO_ROOT = path.join(import.meta.dirname, "../../.."); const CONFIG = JSON.parse( @@ -133,21 +129,6 @@ function exceptionPolicy( } describe("reviewed npm audit gate", () => { - it("selects only a configured locked graph for dedicated evidence production (#11088)", () => { - const auditConfig = parseAuditConfig( - fs.readFileSync(path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"), "utf8"), - ); - - expect(selectLockedGraph(auditConfig, "mcporter-runtime")).toMatchObject({ - graph: { id: "mcporter-runtime" }, - index: auditConfig.lockedGraphs.findIndex(({ id }) => id === "mcporter-runtime"), - }); - expect(selectLockedGraph(auditConfig, undefined)).toBeUndefined(); - expect(() => selectLockedGraph(auditConfig, "unknown-graph")).toThrow( - "reviewed npm audit locked graph is not configured", - ); - }); - it("removes the checked-in brace-expansion exception after remediation (#8116)", () => { expect(CHECKED_IN_POLICY).toEqual(EMPTY_POLICY); }); @@ -337,8 +318,7 @@ describe("reviewed npm audit gate", () => { path.join(REPO_ROOT, ".github", "workflows"), ); - expect(callers).toHaveLength(5); - expect(callers.map(({ timeoutMinutes }) => timeoutMinutes)).toEqual([25, 25, 25, 25, 25]); + expect(callers).toHaveLength(6); expect(Math.min(...callers.map(({ timeoutMinutes }) => timeoutMinutes))).toBeGreaterThanOrEqual( minimumJobTimeoutMinutes, ); diff --git a/test/e2e/support/managed-image-protected-runtime-workflow.test.ts b/test/e2e/support/managed-image-protected-runtime-workflow.test.ts index 072a7ed9bd4..6af10708946 100644 --- a/test/e2e/support/managed-image-protected-runtime-workflow.test.ts +++ b/test/e2e/support/managed-image-protected-runtime-workflow.test.ts @@ -348,31 +348,33 @@ describe("protected managed-image runtime workflow", () => { runtimeJob(value).needs = ["generate-matrix"]; expect(validateManagedImageProtectedRuntimeWorkflow(value)).toContain( - "managed-image-protected-runtime must depend on base-image-publication, generate-matrix, managed-image-multiarch-startup, and managed-image-protected-audit", + "managed-image-protected-runtime must depend on base-image-publication, generate-matrix, and managed-image-multiarch-startup", ); }); it("keeps protected audit production in trusted workflow code", () => { const value = workflow(); - const audit = namedJobStep( + const audit = namedStep( value, - "managed-image-protected-audit", - "Audit exact candidate mcporter graph from trusted code", + "Reuse or refresh reviewed audit evidence before the offline build", ); - audit.uses = "./.candidate-audit/.github/actions/ci-reviewed-npm-audit"; + audit.uses = "./.candidate-runtime/.github/actions/ci-reviewed-npm-audit"; expect(validateManagedImageProtectedRuntimeWorkflow(value)).toContain( - "managed-image-protected-audit must execute the trusted reviewed npm audit action", + "managed-image-protected-runtime must execute the trusted reviewed npm audit action", ); }); - it("requires the trusted audit artifact at the protected consumer", () => { + it("audits the selected candidate before the protected build", () => { const value = workflow(); - const download = namedStep(value, "Download trusted protected mcporter audit evidence"); - (download.with as Record).path = ".candidate-runtime/reviewed-npm-audit"; + const audit = namedStep( + value, + "Reuse or refresh reviewed audit evidence before the offline build", + ); + (audit.with as Record)["target-root"] = "${{ github.workspace }}"; expect(validateManagedImageProtectedRuntimeWorkflow(value)).toContain( - "managed-image-protected-runtime audit evidence download must bind path to ${{ runner.temp }}/protected-reviewed-npm-audit", + "managed-image-protected-runtime audit action must bind target-root to ${{ github.workspace }}/.candidate-runtime", ); }); diff --git a/test/inference/managed/managed-image-publication-workflow.test.ts b/test/inference/managed/managed-image-publication-workflow.test.ts index da87dea2fc1..d91cfe5d78d 100644 --- a/test/inference/managed/managed-image-publication-workflow.test.ts +++ b/test/inference/managed/managed-image-publication-workflow.test.ts @@ -94,7 +94,6 @@ describe("complete managed-image publication workflow", () => { ); expect(action.inputs).toMatchObject({ "cache-directory": { required: true }, - "locked-graph": { default: "", required: false }, "trusted-cache-write": { default: "false", required: false }, }); expect(restores).toHaveLength(2); @@ -130,7 +129,6 @@ describe("complete managed-image publication workflow", () => { ).env, ).toMatchObject({ NEMOCLAW_REVIEWED_NPM_AUDIT_CACHE_DIR: "${{ inputs.cache-directory }}", - NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH: "${{ inputs.locked-graph }}", NPM_CONFIG_USERCONFIG: "/dev/null", }); diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index e19467df21f..5b740214b26 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -239,41 +239,6 @@ describe("mcporter image supply-chain controls", () => { expect(contents).toContain("StreamableHTTPServerTransport"); }); - it("carries a networked reviewed audit into the offline protected OpenClaw build", () => { - const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); - const workflow = fs.readFileSync(path.join(repoRoot, ".github/workflows/e2e.yaml"), "utf8"); - const protectedController = fs.readFileSync( - path.join(repoRoot, "scripts/checks/build-protected-managed-images.sh"), - "utf8", - ); - const installStart = contents.indexOf("# Upgrade stale bases."); - const installEnd = contents.indexOf("# Patch OpenClaw media fetch", installStart); - const protectedInstall = contents.slice(installStart, installEnd); - - expect(workflow).toContain("uses: ./.github/actions/ci-reviewed-npm-audit"); - expect(workflow).toContain("locked-graph: mcporter-runtime"); - expect(workflow).toContain('--audit-evidence-from "$RUNNER_TEMP/protected-reviewed-npm-audit"'); - expect(protectedController).toContain( - 'trusted_receipt_verifier="$controller_root/scripts/lib/npm-audit-receipt.mts"', - ); - expect(protectedController).toContain( - '--package-json "$source_root/agents/openclaw/mcporter-runtime/package.json"', - ); - expect(contents).toContain( - "COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh", - ); - expect(contents).toContain( - "--mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false", - ); - expect(contents).not.toContain("from=protected-mcporter-audit-cache"); - expect(mcporterAuditHelper).toContain( - "cached mcporter audit requires paired receipt, raw report, and receipt SHA-256", - ); - expect(mcporterAuditHelper).toContain("build-context mcporter audit evidence is not trusted"); - expect(installStart).toBeGreaterThanOrEqual(0); - expect(installEnd).toBeGreaterThan(installStart); - expect(protectedInstall).not.toMatch(/RUN --network=(?:default|host)/); - }); it("copies the cached base-image audit report only after receipt verification succeeds", () => { const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile.base"), "utf8"); diff --git a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts index 18134763a57..5883ddea0e6 100644 --- a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts +++ b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts @@ -13,11 +13,8 @@ type WorkflowStep = WorkflowRecord & { }; const JOB_ID = "managed-image-protected-runtime"; -const AUDIT_JOB_ID = "managed-image-protected-audit"; const SELECTOR = - "${{ always() && github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && needs['base-image-publication'].result == 'success' && needs['generate-matrix'].result == 'success' && needs['managed-image-multiarch-startup'].result == 'success' && needs['managed-image-protected-audit'].result == 'success' && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }}"; -const AUDIT_SELECTOR = - "${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }}"; + "${{ always() && github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && needs['base-image-publication'].result == 'success' && needs['generate-matrix'].result == 'success' && needs['managed-image-multiarch-startup'].result == 'success' && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }}"; const ACTIVATION_PATH = "ci/protected-managed-image-runtime-activation-v1.json"; const LIVE_TEST_PATH = "test/e2e/live/managed-image-protected-runtime.test.ts"; const REGISTRY_IMAGE = @@ -94,77 +91,6 @@ function requireOrderedSteps( export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowRecord): string[] { const errors: string[] = []; - const auditJob = record(record(workflow.jobs)[AUDIT_JOB_ID]); - if (Object.keys(auditJob).length === 0) { - errors.push(`workflow missing ${AUDIT_JOB_ID} job`); - } else { - if (auditJob.needs !== "generate-matrix") { - errors.push(`${AUDIT_JOB_ID} must depend on generate-matrix`); - } - if (auditJob.if !== AUDIT_SELECTOR) { - errors.push(`${AUDIT_JOB_ID} must use the protected runtime execution plan`); - } - if (auditJob["runs-on"] !== "ubuntu-24.04" || auditJob["timeout-minutes"] !== 25) { - errors.push(`${AUDIT_JOB_ID} must keep its reviewed hosted runner and timeout`); - } - if (!isDeepStrictEqual(auditJob.permissions, { contents: "read" })) { - errors.push(`${AUDIT_JOB_ID} permissions must be exactly contents: read`); - } - const auditSteps = steps(auditJob.steps); - const trustedAuditCheckout = auditSteps.find( - (step) => step.name === "Checkout trusted reviewed npm audit", - ); - const candidateAuditCheckout = auditSteps.find( - (step) => step.name === "Checkout exact protected audit target", - ); - const trustedAudit = auditSteps.find( - (step) => step.name === "Audit exact candidate mcporter graph from trusted code", - ); - const checkoutAction = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"; - if ( - trustedAuditCheckout?.uses !== checkoutAction || - candidateAuditCheckout?.uses !== checkoutAction - ) { - errors.push(`${AUDIT_JOB_ID} must pin both trusted and candidate checkouts`); - } - requireValues(errors, `${AUDIT_JOB_ID} trusted checkout`, record(trustedAuditCheckout?.with), { - repository: "${{ github.repository }}", - ref: "${{ inputs.workflow_sha || github.workflow_sha }}", - "persist-credentials": false, - }); - const trustedSparseCheckout = text(record(trustedAuditCheckout?.with)["sparse-checkout"]); - for (const trustedPath of [ - ".github/actions/ci-reviewed-npm-audit", - "ci/npm-audit-exceptions.json", - "ci/reviewed-npm-audit.json", - "scripts/audit-reviewed-npm-graph.mts", - "scripts/lib/npm-audit-receipt.mts", - ]) { - if (!trustedSparseCheckout.split("\n").includes(trustedPath)) { - errors.push(`${AUDIT_JOB_ID} trusted checkout must include ${trustedPath}`); - } - } - requireValues( - errors, - `${AUDIT_JOB_ID} candidate checkout`, - record(candidateAuditCheckout?.with), - { - repository: "${{ inputs.checkout_repository || github.repository }}", - ref: "${{ inputs.checkout_sha || github.sha }}", - path: ".candidate-audit", - "persist-credentials": false, - }, - ); - if (trustedAudit?.uses !== "./.github/actions/ci-reviewed-npm-audit") { - errors.push(`${AUDIT_JOB_ID} must execute the trusted reviewed npm audit action`); - } - requireValues(errors, `${AUDIT_JOB_ID} action`, record(trustedAudit?.with), { - "target-root": "${{ github.workspace }}/.candidate-audit", - "report-dir": "artifacts/reviewed-npm-audit", - "cache-directory": "${{ runner.temp }}/reviewed-npm-audit-cache", - "locked-graph": "mcporter-runtime", - }); - } const job = record(record(workflow.jobs)[JOB_ID]); if (Object.keys(job).length === 0) return [`workflow missing ${JOB_ID} job`]; @@ -173,11 +99,10 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR "base-image-publication", "generate-matrix", "managed-image-multiarch-startup", - "managed-image-protected-audit", ]) ) { errors.push( - `${JOB_ID} must depend on base-image-publication, generate-matrix, managed-image-multiarch-startup, and managed-image-protected-audit`, + `${JOB_ID} must depend on base-image-publication, generate-matrix, and managed-image-multiarch-startup`, ); } if (job.if !== SELECTOR) errors.push(`${JOB_ID} must use the trusted execution plan`); @@ -296,19 +221,18 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR name: "${{ env.NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE_ARTIFACT }}", path: "${{ env.NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE }}", }); - const auditDownload = requireStep( + const audit = requireStep( errors, workflowSteps, - "Download trusted protected mcporter audit evidence", + "Reuse or refresh reviewed audit evidence before the offline build", ); - if ( - auditDownload?.uses !== "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" - ) { - errors.push(`${JOB_ID} must pin the reviewed audit evidence download action`); + if (audit?.uses !== "./.github/actions/ci-reviewed-npm-audit") { + errors.push(`${JOB_ID} must execute the trusted reviewed npm audit action`); } - requireValues(errors, `${JOB_ID} audit evidence download`, record(auditDownload?.with), { - name: "reviewed-npm-audit", - path: "${{ runner.temp }}/protected-reviewed-npm-audit", + requireValues(errors, `${JOB_ID} audit action`, record(audit?.with), { + "target-root": "${{ github.workspace }}/.candidate-runtime", + "report-dir": "artifacts/reviewed-npm-audit", + "cache-directory": "${{ runner.temp }}/reviewed-npm-audit-cache", }); const buildx = requireStep(errors, workflowSteps, "Set up protected runtime Buildx"); @@ -406,7 +330,7 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR "--platform linux/amd64", '--source-root "$GITHUB_WORKSPACE/.candidate-runtime"', '--cache-from "$NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE"', - '--audit-evidence-from "$RUNNER_TEMP/protected-reviewed-npm-audit"', + '--audit-evidence-from "$GITHUB_WORKSPACE/.candidate-runtime/artifacts/reviewed-npm-audit"', '--openclaw-base "$BASE_OPENCLAW"', '--hermes-base "$BASE_HERMES"', '--dcode-base "$BASE_DCODE"', @@ -477,8 +401,8 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR "Validate protected runtime exact-head dispatch", "Checkout trusted protected runtime qualification", "Checkout exact protected runtime candidate source", + "Reuse or refresh reviewed audit evidence before the offline build", "Download exact protected runtime build cache", - "Download trusted protected mcporter audit evidence", "Prepare E2E workspace", "Validate protected runtime activation contract", "Resolve reviewed Hermes runtime base image", diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index 8211bff1539..42f598c4893 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -1304,14 +1304,17 @@ function requireFullShaAction( } } -function isReviewedLocalHermesPlatformAction(jobName: string, step: WorkflowStep): boolean { +function isReviewedLocalAction(jobName: string, step: WorkflowStep): boolean { return ( (jobName === "managed-image-multiarch-startup" && step.name === "Resolve reviewed Hermes platform base image" && step.uses === TRUSTED_MULTIARCH_HERMES_PLATFORM_ACTION) || (jobName === "managed-image-protected-runtime" && step.name === "Resolve reviewed Hermes runtime base image" && - step.uses === REVIEWED_HERMES_PLATFORM_ACTION) + step.uses === REVIEWED_HERMES_PLATFORM_ACTION) || + (jobName === "managed-image-protected-runtime" && + step.name === "Reuse or refresh reviewed audit evidence before the offline build" && + step.uses === "./.github/actions/ci-reviewed-npm-audit") ); } @@ -1421,7 +1424,7 @@ function validateFreeStandingInventoryBoundary( const steps = asSteps(job.steps); requireNoDispatchInputInterpolation(errors, steps); for (const step of steps) { - if (step.uses && !isReviewedLocalHermesPlatformAction(jobName, step)) { + if (step.uses && !isReviewedLocalAction(jobName, step)) { requireFullShaAction(errors, step, `${jobName} step '${step.name ?? step.uses}'`); } if (/\$\{\{\s*secrets\./.test(stringValue(step.run))) { From 40c25543f31b86f8e233d99ad1662a80d4af7e50 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 9 Sep 2026 08:44:01 -0400 Subject: [PATCH 35/56] fix(ci): scope reviewed audit cache identity Signed-off-by: Julie Yaunches --- .../actions/ci-reviewed-npm-audit/action.yaml | 29 ++++++- .../checks/build-protected-managed-images.sh | 9 +- .../reviewed-npm-audit-cache-key.test.ts | 87 ++++++++++++++++--- .../reviewed-npm-audit-workflow.test.ts | 1 + 4 files changed, 108 insertions(+), 18 deletions(-) diff --git a/.github/actions/ci-reviewed-npm-audit/action.yaml b/.github/actions/ci-reviewed-npm-audit/action.yaml index 512885ffdae..0194051b9bc 100644 --- a/.github/actions/ci-reviewed-npm-audit/action.yaml +++ b/.github/actions/ci-reviewed-npm-audit/action.yaml @@ -36,6 +36,7 @@ runs: shell: bash env: NEMOCLAW_REVIEWED_NPM_AUDIT_CACHE_DIRECTORY: ${{ inputs.cache-directory }} + NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH: ${{ inputs.locked-graph }} NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT: ${{ inputs.target-root }} run: | set -euo pipefail @@ -56,15 +57,39 @@ runs: const { resolvePathWithinRoot } = await import(pathToFileURL(resolverFile).href); const configSource = readFileSync(configFile, "utf8"); const config = JSON.parse(configSource); + const selectedGraphId = process.env.NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH ?? ""; if (!/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/.test(config.npmVersion) || /[\r\n]/.test(config.npmVersion)) { throw new Error("reviewed npm audit configuration has an invalid npmVersion"); } if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(config.npmIntegrity) || /[\r\n]/.test(config.npmIntegrity)) { throw new Error("reviewed npm audit configuration has an invalid npmIntegrity"); } - const directories = ["", ...config.lockedGraphs.map((graph) => graph.directory)].sort(); + if (!Array.isArray(config.lockedGraphs)) { + throw new Error("reviewed npm audit configuration has invalid lockedGraphs"); + } + const selectedGraph = selectedGraphId + ? config.lockedGraphs.find((graph) => graph.id === selectedGraphId) + : undefined; + if (selectedGraphId && !selectedGraph) { + throw new Error("reviewed npm audit locked graph is not configured"); + } + const directories = selectedGraph + ? [selectedGraph.directory] + : ["", ...config.lockedGraphs.map((graph) => graph.directory)].sort(); + const configIdentity = selectedGraph + ? JSON.stringify({ + exceptionFile: config.exceptionFile, + lockedGraph: selectedGraph, + nodeVersion: config.nodeVersion, + npmIntegrity: config.npmIntegrity, + npmVersion: config.npmVersion, + registryOrigin: config.registryOrigin, + schemaVersion: config.schemaVersion, + severityThreshold: config.severityThreshold, + }) + : configSource; const hash = createHash("sha256"); - hash.update(configSource); + hash.update(configIdentity); hash.update(JSON.stringify({ argv: ["audit", "--registry=https://registry.yarnpkg.com", "--omit=dev", "--json"], npmVersion: config.npmVersion, registry: "https://registry.yarnpkg.com/", schemaVersion: 1 })); for (const directory of directories) { for (const file of ["package.json", "package-lock.json"]) { diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index 92f67fe0e91..8474abe6742 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -334,8 +334,7 @@ confirm_build_retry_state() { run_build_with_retry() { local agent="$1" local image_repository="$2" - local retry_cleanup="$3" - shift 3 + shift 2 local -a build_command=("$@") local attempt_log="$work_dir/${agent}-build-attempt.log" local max_attempts=2 @@ -383,9 +382,7 @@ run_build_with_retry() { return "$build_status" fi - if [[ -n "$retry_cleanup" ]]; then - rm -rf -- "$retry_cleanup" - elif ! confirm_build_retry_state "$agent" "$image_repository"; then + if ! confirm_build_retry_state "$agent" "$image_repository"; then echo "::error::Protected managed-image build outcome=failed-no-retry agent=${agent} attempt=${attempt}/${max_attempts} failure=state-check" >&2 return "$build_status" fi @@ -471,7 +468,7 @@ build_agent() { --build-arg "NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root" --build-arg "TARGETARCH=${target_arch}" "$source_root") - run_build_with_retry "$agent" "$image_repository" "" "${build_command[@]}" + run_build_with_retry "$agent" "$image_repository" "${build_command[@]}" local digest digest="$(jq -er '."containerimage.digest"' "$metadata")" diff --git a/test/automation/releases/reviewed-npm-audit-cache-key.test.ts b/test/automation/releases/reviewed-npm-audit-cache-key.test.ts index 8a0f4dddf9e..4a4d2234caa 100644 --- a/test/automation/releases/reviewed-npm-audit-cache-key.test.ts +++ b/test/automation/releases/reviewed-npm-audit-cache-key.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -20,6 +21,42 @@ type CompositeAction = { const REPO_ROOT = path.join(import.meta.dirname, "../../.."); +function cacheBucketStep(): WorkflowStep { + const action = YAML.parse( + fs.readFileSync( + path.join(REPO_ROOT, ".github", "actions", "ci-reviewed-npm-audit", "action.yaml"), + "utf8", + ), + ) as CompositeAction; + const step = action.runs.steps?.find( + (candidate) => candidate.name === "Resolve reviewed npm audit cache buckets", + ); + expect(step).toBeDefined(); + return step as WorkflowStep; +} + +function resolveCacheIdentity( + root: string, + targetRoot: string, + lockedGraph = "", +): Readonly<{ digest?: string; result: ReturnType }> { + const outputFile = path.join(root, `github-output-${randomUUID()}`); + const result = spawnSync("bash", ["-c", cacheBucketStep().run ?? ""], { + cwd: REPO_ROOT, + encoding: "utf8", + env: { + ...process.env, + GITHUB_ACTION_PATH: path.join(REPO_ROOT, ".github", "actions", "ci-reviewed-npm-audit"), + GITHUB_OUTPUT: outputFile, + NEMOCLAW_REVIEWED_NPM_AUDIT_CACHE_DIRECTORY: path.join(root, "cache"), + NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH: lockedGraph, + NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT: targetRoot, + }, + }); + const output = fs.existsSync(outputFile) ? fs.readFileSync(outputFile, "utf8") : ""; + return { digest: /^input-digest=(.+)$/m.exec(output)?.[1], result }; +} + function copyGraphInputs(targetRoot: string, directory: string) { const sourceDirectory = path.join(REPO_ROOT, directory); const targetDirectory = path.join(targetRoot, directory); @@ -35,6 +72,45 @@ function copyGraphInputs(targetRoot: string, directory: string) { } describe("reviewed npm audit cache identity", () => { + it("isolates a selected locked graph from unrelated graph inputs", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-audit-cache-key-")); + const targetRoot = path.join(root, "target"); + try { + copyGraphInputs(targetRoot, ""); + copyGraphInputs(targetRoot, "agents/openclaw/openclaw-runtime"); + copyGraphInputs(targetRoot, "agents/openclaw/mcporter-runtime"); + copyGraphInputs(targetRoot, "agents/openclaw/wechat-runtime"); + copyGraphInputs(targetRoot, "tools/mcp-tool-discovery-runtime"); + + const initialAllGraphs = resolveCacheIdentity(root, targetRoot); + expect(initialAllGraphs.result.status, String(initialAllGraphs.result.stderr)).toBe(0); + const initial = resolveCacheIdentity(root, targetRoot, "mcporter-runtime"); + expect(initial.result.status, String(initial.result.stderr)).toBe(0); + expect(initial.digest).toMatch(/^[a-f0-9]{64}$/); + + fs.appendFileSync( + path.join(targetRoot, "agents/openclaw/openclaw-runtime/package.json"), + "\n", + ); + const unrelatedChange = resolveCacheIdentity(root, targetRoot, "mcporter-runtime"); + expect(unrelatedChange.result.status, String(unrelatedChange.result.stderr)).toBe(0); + expect(unrelatedChange.digest).toBe(initial.digest); + const changedAllGraphs = resolveCacheIdentity(root, targetRoot); + expect(changedAllGraphs.result.status, String(changedAllGraphs.result.stderr)).toBe(0); + expect(changedAllGraphs.digest).not.toBe(initialAllGraphs.digest); + + fs.appendFileSync( + path.join(targetRoot, "agents/openclaw/mcporter-runtime/package.json"), + "\n", + ); + const selectedChange = resolveCacheIdentity(root, targetRoot, "mcporter-runtime"); + expect(selectedChange.result.status, String(selectedChange.result.stderr)).toBe(0); + expect(selectedChange.digest).not.toBe(initial.digest); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it("rejects a target input symbolic link before emitting a cache identity", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-audit-cache-key-")); const targetRoot = path.join(root, "target"); @@ -50,16 +126,7 @@ describe("reviewed npm audit cache identity", () => { fs.rmSync(path.join(targetRoot, "package.json")); fs.symlinkSync(externalFile, path.join(targetRoot, "package.json")); - const action = YAML.parse( - fs.readFileSync( - path.join(REPO_ROOT, ".github", "actions", "ci-reviewed-npm-audit", "action.yaml"), - "utf8", - ), - ) as CompositeAction; - const cacheBucketStep = action.runs.steps?.find( - (step) => step.name === "Resolve reviewed npm audit cache buckets", - ); - const result = spawnSync("bash", ["-c", cacheBucketStep?.run ?? ""], { + const result = spawnSync("bash", ["-c", cacheBucketStep().run ?? ""], { cwd: REPO_ROOT, encoding: "utf8", env: { diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index c634502fd92..e4f8ebc338b 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -337,6 +337,7 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { expect(cacheBucketStep.env).toEqual({ NEMOCLAW_REVIEWED_NPM_AUDIT_CACHE_DIRECTORY: "${{ inputs.cache-directory }}", + NEMOCLAW_REVIEWED_NPM_AUDIT_LOCKED_GRAPH: "${{ inputs.locked-graph }}", NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT: "${{ inputs.target-root }}", }); expect(cacheBucketStep.run).toContain( From 8ab0aa41c9897cfc32f6c5c0fc8d50c6de42491e Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 9 Sep 2026 09:24:38 -0400 Subject: [PATCH 36/56] test(ci): verify paired audit evidence handoff Signed-off-by: Julie Yaunches --- .../images/protected-managed-image-build-script.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index 20bfaa5e009..2ca4509f896 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -632,6 +632,9 @@ describe("protected managed-image build-cache boundary", () => { expect(recordedBuildInvocation("openclaw")).toContain( `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(auditRoot)}/mcporter-runtime.receipt.json`, ); + expect(recordedBuildInvocation("openclaw")).toContain( + `--secret id=nemoclaw-mcporter-audit-raw-report,src=${realpathSync(auditRoot)}/mcporter-runtime.raw.json`, + ); expect(recordedBuildInvocation("openclaw")).toContain( `--build-arg NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=${DIGEST}`, ); From 663d7cfdadff4eca23e8930a8e3175fd1afd505c Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 9 Sep 2026 10:23:49 -0400 Subject: [PATCH 37/56] fix(ci): centralize protected audit authority --- .../build-base-image-platform/action.yaml | 10 +- .github/workflows/base-image-platform.yaml | 1 + .github/workflows/managed-images.yaml | 59 +++++- Dockerfile | 3 +- Dockerfile.base | 3 +- agents/openclaw/dependency-review.md | 9 +- ci/source-shape-test-budget.json | 5 + scripts/audit-reviewed-npm-graph.mts | 7 + .../checks/build-protected-managed-images.sh | 16 +- scripts/lib/verify-mcporter-audit.sh | 30 +-- src/lib/sandbox/build-context.ts | 4 - .../reviewed-npm-audit-handoff.test.ts | 187 +++++++++++++++--- ...managed-image-publication-workflow.test.ts | 4 + ...otected-managed-image-build-script.test.ts | 40 ++-- .../sandbox/sandbox-build-context.test.ts | 1 - test/security/mcporter-supply-chain.test.ts | 31 +-- 16 files changed, 308 insertions(+), 102 deletions(-) diff --git a/.github/actions/build-base-image-platform/action.yaml b/.github/actions/build-base-image-platform/action.yaml index 51050771ef6..6abf11a4eb7 100644 --- a/.github/actions/build-base-image-platform/action.yaml +++ b/.github/actions/build-base-image-platform/action.yaml @@ -52,6 +52,10 @@ inputs: description: Optional same-run reviewed mcporter raw audit report path. required: false default: "" + mcporter-audit-policy-result: + description: Optional same-run reviewed mcporter audit policy result path. + required: false + default: "" runs: using: composite @@ -74,6 +78,7 @@ runs: OPENCLAW_VERSION_INPUT: ${{ inputs.openclaw-version }} MCPORTER_AUDIT_RECEIPT: ${{ inputs.mcporter-audit-receipt }} MCPORTER_AUDIT_RAW_REPORT: ${{ inputs.mcporter-audit-raw-report }} + MCPORTER_AUDIT_POLICY_RESULT: ${{ inputs.mcporter-audit-policy-result }} run: | set -euo pipefail build_args=() @@ -101,7 +106,9 @@ runs: if [ "$AGENT" = "openclaw" ] && [ -n "${MCPORTER_AUDIT_RECEIPT:-}" ]; then test -f "$MCPORTER_AUDIT_RECEIPT" test -f "${MCPORTER_AUDIT_RAW_REPORT:-}" - audit_build_args="NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=$(sha256sum "$MCPORTER_AUDIT_RECEIPT" | cut -d' ' -f1)" + test -f "${MCPORTER_AUDIT_POLICY_RESULT:-}" + audit_build_args="NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=$(sha256sum "$MCPORTER_AUDIT_RECEIPT" | cut -d' ' -f1) + NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256=$(sha256sum "$MCPORTER_AUDIT_POLICY_RESULT" | cut -d' ' -f1)" fi printf 'openclaw_build_arg=%s\n' "$openclaw_build_arg" >> "$GITHUB_OUTPUT" if [ -n "$audit_build_args" ]; then @@ -127,6 +134,7 @@ runs: secret-files: | ${{ inputs.agent == 'openclaw' && format('nemoclaw-mcporter-audit-receipt={0}', inputs.mcporter-audit-receipt) || '' }} ${{ inputs.agent == 'openclaw' && format('nemoclaw-mcporter-audit-raw-report={0}', inputs.mcporter-audit-raw-report) || '' }} + ${{ inputs.agent == 'openclaw' && format('nemoclaw-mcporter-audit-policy-result={0}', inputs.mcporter-audit-policy-result) || '' }} - name: Validate Deep Agents Code dos2unix executable if: ${{ inputs.agent == 'langchain-deepagents-code' }} diff --git a/.github/workflows/base-image-platform.yaml b/.github/workflows/base-image-platform.yaml index 4e871b74355..f67290d046f 100644 --- a/.github/workflows/base-image-platform.yaml +++ b/.github/workflows/base-image-platform.yaml @@ -86,3 +86,4 @@ jobs: openclaw-version: ${{ inputs.openclaw-version }} mcporter-audit-receipt: ${{ inputs.agent == 'openclaw' && format('{0}/reviewed-npm-audit/mcporter-runtime.receipt.json', runner.temp) || '' }} mcporter-audit-raw-report: ${{ inputs.agent == 'openclaw' && format('{0}/reviewed-npm-audit/mcporter-runtime.raw.json', runner.temp) || '' }} + mcporter-audit-policy-result: ${{ inputs.agent == 'openclaw' && format('{0}/reviewed-npm-audit/mcporter-runtime.policy.json', runner.temp) || '' }} diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index aebdea56e62..bb3907c59a1 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -482,6 +482,25 @@ jobs: name: reviewed-npm-audit path: ${{ runner.temp }}/reviewed-npm-audit + - name: Checkout trusted mcporter audit verifier + if: matrix.agent == 'openclaw' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: .trusted-mcporter-audit + persist-credentials: false + sparse-checkout: | + ci/npm-audit-exceptions.json + ci/reviewed-npm-audit.json + scripts/lib/npm-audit-receipt.mts + scripts/lib/reviewed-npm-audit.mts + sparse-checkout-cone-mode: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.19.0 + - name: Prepare same-run mcporter audit evidence if: matrix.agent == 'openclaw' id: mcporter-audit @@ -490,18 +509,29 @@ jobs: set -euo pipefail receipt="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.receipt.json" raw="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.raw.json" - test -f "$receipt"; test -f "$raw" - printf 'receipt=%s\nraw=%s\nreceipt_sha256=%s\n' "$receipt" "$raw" "$(sha256sum "$receipt" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + policy="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.policy.json" + trusted_root="$GITHUB_WORKSPACE/.trusted-mcporter-audit" + test "$(git -C "$trusted_root" rev-parse --verify HEAD)" = '${{ github.event.pull_request.base.sha }}' + node --experimental-strip-types --no-warnings \ + "$trusted_root/scripts/lib/npm-audit-receipt.mts" \ + --receipt "$receipt" \ + --package-json "$GITHUB_WORKSPACE/agents/openclaw/mcporter-runtime/package.json" \ + --package-lock "$GITHUB_WORKSPACE/agents/openclaw/mcporter-runtime/package-lock.json" \ + --raw-report "$raw" \ + --exceptions "$trusted_root/ci/npm-audit-exceptions.json" \ + --graph mcporter-runtime \ + --audit-config "$trusted_root/ci/reviewed-npm-audit.json" \ + --registry https://registry.yarnpkg.com \ + --threshold high \ + --legacy-npmjs true \ + --result "$policy" + test -f "$receipt"; test -f "$raw"; test -s "$policy"; test ! -L "$policy" + printf 'receipt=%s\nraw=%s\npolicy=%s\nreceipt_sha256=%s\npolicy_sha256=%s\n' "$receipt" "$raw" "$policy" "$(sha256sum "$receipt" | cut -d' ' -f1)" "$(sha256sum "$policy" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22.19.0 - - name: Validate Deep Agents PR base build arguments if: matrix.agent == 'langchain-deepagents-code' shell: bash @@ -550,7 +580,9 @@ jobs: RESOLUTION_LABEL: ${{ steps.base.outputs.resolution_label }} MCPORTER_AUDIT_RECEIPT: ${{ steps.mcporter-audit.outputs.receipt }} MCPORTER_AUDIT_RAW_REPORT: ${{ steps.mcporter-audit.outputs.raw }} + MCPORTER_AUDIT_POLICY_RESULT: ${{ steps.mcporter-audit.outputs.policy }} MCPORTER_AUDIT_RECEIPT_SHA256: ${{ steps.mcporter-audit.outputs.receipt_sha256 }} + MCPORTER_AUDIT_POLICY_RESULT_SHA256: ${{ steps.mcporter-audit.outputs.policy_sha256 }} run: | set -euo pipefail # The base resolver loads a changed base into Docker's local image @@ -561,8 +593,10 @@ jobs: if [ "$AGENT" = "openclaw" ]; then audit_options+=( --build-arg "NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=${MCPORTER_AUDIT_RECEIPT_SHA256}" + --build-arg "NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256=${MCPORTER_AUDIT_POLICY_RESULT_SHA256}" --secret "id=nemoclaw-mcporter-audit-receipt,src=${MCPORTER_AUDIT_RECEIPT}" --secret "id=nemoclaw-mcporter-audit-raw-report,src=${MCPORTER_AUDIT_RAW_REPORT}" + --secret "id=nemoclaw-mcporter-audit-policy-result,src=${MCPORTER_AUDIT_POLICY_RESULT}" ) fi if [ "$AGENT" = "langchain-deepagents-code" ]; then @@ -618,9 +652,11 @@ jobs: NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1 NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root ${{ matrix.agent == 'openclaw' && format('NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256={0}', steps.mcporter-audit.outputs.receipt_sha256) || '' }} + ${{ matrix.agent == 'openclaw' && format('NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256={0}', steps.mcporter-audit.outputs.policy_sha256) || '' }} secret-files: | ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-receipt={0}', steps.mcporter-audit.outputs.receipt) || '' }} ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-raw-report={0}', steps.mcporter-audit.outputs.raw) || '' }} + ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-policy-result={0}', steps.mcporter-audit.outputs.policy) || '' }} cache-from: type=registry,ref=ghcr.io/nvidia/nemoclaw/${{ matrix.agent }}-sandbox:buildcache-linux-amd64 provenance: false sbom: false @@ -853,9 +889,11 @@ jobs: NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1 NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root ${{ matrix.agent == 'openclaw' && format('NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256={0}', steps.mcporter-audit.outputs.receipt_sha256) || '' }} + ${{ matrix.agent == 'openclaw' && format('NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256={0}', steps.mcporter-audit.outputs.policy_sha256) || '' }} secret-files: | ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-receipt={0}', steps.mcporter-audit.outputs.receipt) || '' }} ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-raw-report={0}', steps.mcporter-audit.outputs.raw) || '' }} + ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-policy-result={0}', steps.mcporter-audit.outputs.policy) || '' }} cache-from: type=registry,ref=ghcr.io/nvidia/nemoclaw/${{ matrix.agent }}-sandbox:buildcache-linux-amd64 provenance: false sbom: false @@ -1736,8 +1774,9 @@ jobs: set -euo pipefail receipt="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.receipt.json" raw="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.raw.json" - test -f "$receipt"; test -f "$raw" - printf 'receipt=%s\nraw=%s\nreceipt_sha256=%s\n' "$receipt" "$raw" "$(sha256sum "$receipt" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + policy="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.policy.json" + test -f "$receipt"; test -f "$raw"; test -f "$policy" + printf 'receipt=%s\nraw=%s\npolicy=%s\nreceipt_sha256=%s\npolicy_sha256=%s\n' "$receipt" "$raw" "$policy" "$(sha256sum "$receipt" | cut -d' ' -f1)" "$(sha256sum "$policy" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - name: Restore exact base image contract shell: bash @@ -1920,9 +1959,11 @@ jobs: NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1 NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root ${{ matrix.agent == 'openclaw' && format('NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256={0}', steps.mcporter-audit.outputs.receipt_sha256) || '' }} + ${{ matrix.agent == 'openclaw' && format('NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256={0}', steps.mcporter-audit.outputs.policy_sha256) || '' }} secret-files: | ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-receipt={0}', steps.mcporter-audit.outputs.receipt) || '' }} ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-raw-report={0}', steps.mcporter-audit.outputs.raw) || '' }} + ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-policy-result={0}', steps.mcporter-audit.outputs.policy) || '' }} cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.artifact_platform }} cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.artifact_platform }},mode=max provenance: mode=max diff --git a/Dockerfile b/Dockerfile index 276b27e1b00..7e71e3a4e36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -544,7 +544,6 @@ COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/ COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts -COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts @@ -636,6 +635,7 @@ ARG MCPORTER_VERSION=0.7.3 ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA== ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256= +ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256= # A cross-stage root copy is accepted by Docker's legacy builder and creates one # final-image layer while preserving metadata on existing parent directories. @@ -821,6 +821,7 @@ RUN command -v codex-acp >/dev/null # hadolint ignore=DL3059,DL4006,DL3016,SC2015 RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ + --mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false \ set -eu; \ if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ diff --git a/Dockerfile.base b/Dockerfile.base index a47bf1b0732..0cea8c2c233 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -414,6 +414,7 @@ ARG MCPORTER_VERSION=0.7.3 ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA== ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256= +ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256= # Keep paired runtime manifests and remediation helpers in grouped layers so # the published base retains its established image layout. COPY agents/openclaw/openclaw-runtime/package.json \ @@ -428,7 +429,6 @@ COPY scripts/lib/reviewed-npm-archive.mts \ scripts/lib/reviewed-npm-audit.mts \ scripts/lib/openclaw-npm-remediation.mts \ /scripts/lib/ -COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts @@ -479,6 +479,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/blueprint.yaml \ --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ + --mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false \ echo "$OPENCLAW_VERSION" | grep -qxE '[0-9]+(\.[0-9]+)*' \ || { echo "Error: OPENCLAW_VERSION='$OPENCLAW_VERSION' is invalid (expected e.g. 2026.3.11)."; exit 1; }; \ OPENCLAW_MIN_VERSION=$(grep -m 1 'min_openclaw_version' /tmp/blueprint.yaml | awk '{print $2}' | tr -d '"'); \ diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index 02474f77db0..023cd79a62a 100644 --- a/agents/openclaw/dependency-review.md +++ b/agents/openclaw/dependency-review.md @@ -28,7 +28,7 @@ Update it and `agents/openclaw/mcporter-runtime/package*.json` together whenever Both image paths install the committed graph with `npm ci --ignore-scripts --omit=dev` because the published package declares no install-time lifecycle script and NemoClaw needs only its already-built CLI. The reviewed audit wrapper reports lower-severity production findings and blocks unaccepted high or critical advisories. The default `ci/npm-audit-exceptions.json` registry is empty. Any future exception must match one advisory, graph, package, installed version, and severity; identify an owner and NemoClaw tracking issue; state a decision, rationale, and expiry no more than 30 days away; and include compensating controls for temporary risk acceptance. Missing, malformed, expired, overlong, mismatched, or unused exceptions fail closed. The repository-wide audit also rejects exceptions for unknown graph IDs. Registry signature verification remains a separate control. -Managed image publication supplies the mcporter receipt and raw report as BuildKit secrets. Before its offline build, the protected job runs the existing trusted audit action against the candidate inputs. The action reuses matching, unexpired audit records or refreshes them through the configured registry. The offline consumer verifies the receipt and raw report before building an image. Other image builds without paired evidence run the reviewed audit directly and fail closed if completeness cannot be established. +Protected runtime qualification supplies the mcporter receipt, raw report, and trusted policy result as BuildKit secrets. Before its offline build, the protected job runs the existing trusted audit action against the candidate inputs and verifies the resulting evidence with policy from the trusted workflow checkout. Standard trusted base- and managed-image publication carries the audit producer's named policy result with the same receipt and raw report. The action reuses matching, unexpired audit records or refreshes them through the configured registry. Offline consumers check the receipt and policy result transport hashes before retaining the trusted result. Other image builds without protected evidence run the reviewed audit directly and fail closed if completeness cannot be established. ## WeChat plugin runtime graph @@ -43,7 +43,7 @@ Managed image publication supplies the mcporter receipt and raw report as BuildK It also exercises the reviewed archive through a copied writable cache while the trusted source remains read-only. Signature verification makes at most three attempts and retries only `npm error Failed to download`; all other failures stop immediately. The shared report artifact stores the audit policy, signature-attempt evidence, and whether each response came from a matching cache entry or a live registry request. - Its mcporter receipt and raw response cross into the image build; the other graph receipts remain CI evidence. + Its mcporter receipt, raw report, and trusted policy result cross into image builds; the other graph receipts remain CI evidence. The archive graph also retains the generated manifest and lock bytes authenticated by its receipt. - Advisory command: `npm ci --ignore-scripts --omit=dev --legacy-peer-deps --prefix agents/openclaw/wechat-runtime && npm audit --registry=https://registry.yarnpkg.com --omit=dev --audit-level=low --json --prefix agents/openclaw/wechat-runtime && npm audit signatures --registry=https://registry.yarnpkg.com --omit=dev --prefix agents/openclaw/wechat-runtime`. - Advisory review: `2026-07-12`; result: `0` known vulnerabilities across the resolved production graph. @@ -58,7 +58,10 @@ The lock records the exact version, registry URL, and integrity for every transi - `invalidState`: the image installs a package graph, tarball, license, or advisory state that differs from the independently queried npm registry records for `mcporter@0.7.3`, resolves `@hono/node-server` to any version other than exact `2.0.11`, resolves `fast-uri` to any version other than exact `3.1.6`, resolves `hono` to any version other than exact `4.12.34`, or resolves `ip-address` to any version other than exact `10.3.1`. - `sourceBoundary`: npm owns registry metadata, tarball integrity, provenance signatures, and advisory responses; NemoClaw owns the exact lock, script-disabled install, Docker integrity assertion, empty-by-default audit exception registry, and review record. - `whyNotSourceFix`: a repository note cannot make external registry state trustworthy, so the required `reviewed-npm-audit` CI check materializes the exact locked production graph and verifies its registry signatures. -- `imageBuildBoundary`: image builds verify the committed lock, registry origin, tarball integrity, installed graph, lifecycle suppression, and reviewed advisory policy without connecting to Sigstore. +- `imageBuildBoundary`: image builds verify the committed lock, registry origin, tarball integrity, installed graph, and lifecycle suppression. + Builds without supplied audit evidence evaluate the reviewed advisory policy directly. + Evidence-backed builds instead verify the receipt and policy-result transport hashes after trusted workflow code validates the candidate graph and policy. + Neither path connects to Sigstore. The `schema=4` and `mcporter-recipe=locked-ci+reviewed-audit-v3` provenance values record this boundary. They do not attest that trusted CI verified registry signatures. - `enforcementBoundary`: any nonzero `npm audit signatures` status fails the required CI check. diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index b2fa12237d6..cee2ef81732 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -196,6 +196,11 @@ "test": "keeps the LKG credential on the production-only dispatch step (#9798)", "category": "security" }, + { + "file": "test/automation/releases/reviewed-npm-audit-handoff.test.ts", + "test": "pairs every production audit receipt with raw and trusted policy results", + "category": "security" + }, { "file": "test/automation/releases/reviewed-npm-audit-workflow.test.ts", "test": "passes the cache identity target root without interpolating it into shell source", diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index 80db38ae096..29fe25d0f54 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -868,8 +868,15 @@ export function emitAuditReceipt( }); const receiptFile = path.join(options.artifactDirectory, `${options.graphId}.receipt.json`); const transportRawFile = path.join(options.artifactDirectory, `${options.graphId}.raw.json`); + const transportPolicyFile = path.join( + options.artifactDirectory, + `${options.graphId}.policy.json`, + ); fs.copyFileSync(options.rawReportFile, transportRawFile); fs.chmodSync(transportRawFile, 0o600); + fs.writeFileSync(transportPolicyFile, `${JSON.stringify(options.result, null, 2)}\n`, { + mode: 0o600, + }); if (options.preserveInputs) { for (const [source, suffix] of [ [options.packageJsonFile, "package.json"], diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index 8474abe6742..471804bb57a 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -218,7 +218,9 @@ trap 'exit 143' TERM audit_receipt="" audit_raw_report="" +audit_policy_result="" audit_receipt_sha256="" +audit_policy_result_sha256="" validate_audit_evidence() { local directory="$1" [[ -d "$directory" && ! -L "$directory" && -z "$(find "$directory" -type l -print -quit)" ]] || { @@ -232,6 +234,7 @@ validate_audit_evidence() { exit 1 } audit_receipt_sha256="$(sha256sum "$audit_receipt" | awk '{print $1}')" + audit_policy_result="$work_dir/mcporter-runtime.policy.json" node --experimental-strip-types --no-warnings "$trusted_receipt_verifier" \ --receipt "$audit_receipt" \ --package-json "$source_root/agents/openclaw/mcporter-runtime/package.json" \ @@ -242,7 +245,13 @@ validate_audit_evidence() { --audit-config "$trusted_audit_config" \ --registry https://registry.yarnpkg.com \ --threshold high \ - --legacy-npmjs true + --legacy-npmjs true \ + --result "$audit_policy_result" + [[ -f "$audit_policy_result" && -s "$audit_policy_result" && ! -L "$audit_policy_result" ]] || { + echo "ERROR: protected managed-image reviewed audit policy result is missing or unsafe" >&2 + exit 1 + } + audit_policy_result_sha256="$(sha256sum "$audit_policy_result" | awk '{print $1}')" } if [[ -n "$cache_from" ]]; then @@ -423,7 +432,9 @@ build_agent() { cache_args+=( --secret "id=nemoclaw-mcporter-audit-receipt,src=${audit_receipt}" --secret "id=nemoclaw-mcporter-audit-raw-report,src=${audit_raw_report}" + --secret "id=nemoclaw-mcporter-audit-policy-result,src=${audit_policy_result}" --build-arg "NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=${audit_receipt_sha256}" + --build-arg "NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256=${audit_policy_result_sha256}" ) fi @@ -461,9 +472,6 @@ build_agent() { --label "io.nvidia.nemoclaw.managed-image.capabilities=1" --label "io.nvidia.nemoclaw.managed-image.cohort=${cohort}" --build-arg "BASE_IMAGE=${base_reference}" - # Dockerfile defaults preserve direct Podman x86 builds. Pass the selected - # Buildx target explicitly so that default cannot override linux/arm64. - --build-arg "TARGETARCH=${platform#linux/}" --build-arg "NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1" --build-arg "NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root" --build-arg "TARGETARCH=${target_arch}" diff --git a/scripts/lib/verify-mcporter-audit.sh b/scripts/lib/verify-mcporter-audit.sh index 41b514b5e2a..7b1d071b2ba 100755 --- a/scripts/lib/verify-mcporter-audit.sh +++ b/scripts/lib/verify-mcporter-audit.sh @@ -6,21 +6,19 @@ set -euo pipefail receipt=/run/secrets/nemoclaw-mcporter-audit-receipt raw_report=/run/secrets/nemoclaw-mcporter-audit-raw-report +policy_result=/run/secrets/nemoclaw-mcporter-audit-policy-result receipt_sha256="${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" +policy_result_sha256="${NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256:-}" seed=/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit report_path="${NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH:-}" result_path="${NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH:-}" audit_output_args=() -receipt_output_args=() [[ -z "$report_path" ]] || audit_output_args+=(--report "$report_path") -if [[ -n "$result_path" ]]; then - audit_output_args+=(--result "$result_path") - receipt_output_args+=(--result "$result_path") -fi +[[ -z "$result_path" ]] || audit_output_args+=(--result "$result_path") -if [[ -e "$receipt" || -L "$receipt" || -e "$raw_report" || -L "$raw_report" || -n "$receipt_sha256" ]]; then - [[ -f "$receipt" && ! -L "$receipt" && -f "$raw_report" && ! -L "$raw_report" && -n "$receipt_sha256" ]] || { - echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2 +if [[ -e "$receipt" || -L "$receipt" || -e "$raw_report" || -L "$raw_report" || -e "$policy_result" || -L "$policy_result" || -n "$receipt_sha256" || -n "$policy_result_sha256" ]]; then + [[ -f "$receipt" && ! -L "$receipt" && -f "$raw_report" && ! -L "$raw_report" && -f "$policy_result" && ! -L "$policy_result" && -n "$receipt_sha256" && -n "$policy_result_sha256" ]] || { + echo "ERROR: cached mcporter audit requires paired receipt, raw report, trusted policy result, and transport SHA-256 values" >&2 exit 1 } elif [[ -e "$seed" || -L "$seed" ]]; then @@ -42,11 +40,13 @@ printf '%s %s\n' "$receipt_sha256" "$receipt" | sha256sum --check --status - || echo "ERROR: cached mcporter audit receipt hash does not match" >&2 exit 1 } -node --experimental-strip-types /scripts/lib/npm-audit-receipt.mts \ - --receipt "$receipt" \ - --package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ - --raw-report "$raw_report" --exceptions /scripts/npm-audit-exceptions.json \ - --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ - --registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true \ - "${receipt_output_args[@]}" +printf '%s' "$policy_result_sha256" | grep -qxE '[0-9a-f]{64}' || { + echo "ERROR: cached mcporter audit policy result SHA-256 is invalid" >&2 + exit 1 +} +printf '%s %s\n' "$policy_result_sha256" "$policy_result" | sha256sum --check --status - || { + echo "ERROR: cached mcporter audit policy result hash does not match" >&2 + exit 1 +} [[ -z "$report_path" ]] || cp -- "$raw_report" "$report_path" +[[ -z "$result_path" ]] || cp -- "$policy_result" "$result_path" diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 6848cd41109..af534bff84d 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -454,10 +454,6 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "lib", "reviewed-npm-audit.mts"), path.join(stagedScriptsDir, "lib", "reviewed-npm-audit.mts"), ); - fs.copyFileSync( - path.join(rootDir, "scripts", "lib", "npm-audit-receipt.mts"), - path.join(stagedScriptsDir, "lib", "npm-audit-receipt.mts"), - ); fs.copyFileSync( path.join(rootDir, "scripts", "lib", "openclaw-npm-remediation.mts"), path.join(stagedScriptsDir, "lib", "openclaw-npm-remediation.mts"), diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index cf5a7604c6f..2fecaac1337 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -25,6 +25,9 @@ type Workflow = { string, { readonly steps?: readonly { + readonly name?: string; + readonly run?: string; + readonly uses?: string; readonly with?: Readonly>; }[]; } @@ -90,7 +93,84 @@ describe("reviewed npm audit handoff", () => { }, ); - it("passes producer output through protected audit handoffs and rejects forged reports", () => { + // source-shape-contract: security -- Every production image builder must keep the trusted three-file audit handoff atomic because GitHub and BuildKit consume these declarations directly. + it("pairs every production audit receipt with raw and trusted policy results", () => { + const managedWorkflow = YAML.parse( + fs.readFileSync(path.join(REPO_ROOT, ".github/workflows/managed-images.yaml"), "utf8"), + ) as Workflow; + const managedSteps = Object.values(managedWorkflow.jobs ?? {}).flatMap( + (job) => job.steps ?? [], + ); + const managedHandoffs = managedSteps + .map((step) => JSON.stringify(step)) + .filter((source) => source.includes("nemoclaw-mcporter-audit-receipt")); + const baseWorkflow = YAML.parse( + fs.readFileSync(path.join(REPO_ROOT, ".github/workflows/base-image-platform.yaml"), "utf8"), + ) as Workflow; + const baseHandoff = JSON.stringify( + baseWorkflow.jobs?.build?.steps?.find( + ({ name }) => name === "Build and publish platform digest", + ), + ); + const baseAction = YAML.parse( + fs.readFileSync( + path.join(REPO_ROOT, ".github/actions/build-base-image-platform/action.yaml"), + "utf8", + ), + ) as { + readonly runs?: { readonly steps?: readonly { readonly name?: string }[] }; + }; + const baseActionHandoff = JSON.stringify( + baseAction.runs?.steps?.find( + ({ name }) => name === "Build and push platform digest", + ), + ); + const baseActionValidation = JSON.stringify( + baseAction.runs?.steps?.find( + ({ name }) => name === "Validate production Docker build args", + ), + ); + const buildKitHandoffs = [...managedHandoffs, baseActionHandoff]; + + expect(managedHandoffs.length).toBeGreaterThan(0); + expect( + buildKitHandoffs.filter( + (source) => !source.includes("nemoclaw-mcporter-audit-receipt"), + ), + ).toEqual([]); + expect( + buildKitHandoffs.filter( + (source) => !source.includes("nemoclaw-mcporter-audit-raw-report"), + ), + ).toEqual([]); + expect( + buildKitHandoffs.filter( + (source) => !source.includes("nemoclaw-mcporter-audit-policy-result"), + ), + ).toEqual([]); + expect( + managedHandoffs.filter( + (source) => !source.includes("NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256"), + ), + ).toEqual([]); + expect(baseActionValidation).toContain( + "NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256", + ); + expect(baseHandoff).toContain("mcporter-audit-receipt"); + expect(baseHandoff).toContain("mcporter-audit-raw-report"); + expect(baseHandoff).toContain("mcporter-audit-policy-result"); + + const prPreparation = managedSteps.find( + ({ name, run }) => name === "Prepare same-run mcporter audit evidence" && run?.includes("trusted_root"), + ); + expect(prPreparation?.run).toContain('"$trusted_root/scripts/lib/npm-audit-receipt.mts"'); + expect(prPreparation?.run).toContain('--result "$policy"'); + expect(prPreparation?.run).not.toContain( + '"$GITHUB_WORKSPACE/scripts/lib/npm-audit-receipt.mts"', + ); + }); + + it("keeps protected audit acceptance under trusted policy and rejects forged transport", () => { const root = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")), ); @@ -162,6 +242,11 @@ describe("reviewed npm audit handoff", () => { const retainedPackageJson = path.join(runtime, "package.json"); const retainedPackageLock = path.join(runtime, "package-lock.json"); const transportRawReport = path.join(artifactDirectory, "mcporter-runtime.raw.json"); + const producerPolicyResult = path.join( + artifactDirectory, + "mcporter-runtime.policy.json", + ); + const trustedPolicyResult = path.join(root, "trusted-policy-result.json"); const receiptVerifier = path.join(trustedRoot, "scripts", "lib", "npm-audit-receipt.mts"); const retainedReport = path.join(root, "retained-report.json"); const retainedResult = path.join(root, "retained-result.json"); @@ -189,7 +274,7 @@ describe("reviewed npm audit handoff", () => { "--legacy-npmjs", "true", "--result", - retainedResult, + trustedPolicyResult, ]; const nodeLog = path.join(root, "node.log"); const stubBin = path.join(root, "bin"); @@ -208,26 +293,29 @@ describe("reviewed npm audit handoff", () => { .replaceAll("/run/secrets/nemoclaw-mcporter-audit-receipt", receiptFile) .replaceAll("/run/secrets/nemoclaw-mcporter-audit-raw-report", transportRawReport) .replaceAll( - "/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", - path.join(root, "no-seed"), + "/run/secrets/nemoclaw-mcporter-audit-policy-result", + trustedPolicyResult, ) - .replaceAll("/scripts/lib/npm-audit-receipt.mts", receiptVerifier) - .replaceAll("/usr/local/lib/nemoclaw/mcporter-runtime/package.json", retainedPackageJson) .replaceAll( - "/usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json", - retainedPackageLock, - ) - .replaceAll("/scripts/npm-audit-exceptions.json", exceptionFile) - .replaceAll("/scripts/reviewed-npm-audit.json", auditConfigFile); + "/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", + path.join(root, "no-seed"), + ); fs.writeFileSync(helper, helperSource, { mode: 0o755 }); - const runHelper = () => + const correctReceiptSha256 = createHash("sha256") + .update(fs.readFileSync(receiptFile)) + .digest("hex"); + const policyResultSha256 = () => + createHash("sha256").update(fs.readFileSync(trustedPolicyResult)).digest("hex"); + const runHelper = ( + receiptSha256 = correctReceiptSha256, + trustedPolicyResultSha256 = policyResultSha256(), + ) => spawnSync("bash", [helper], { encoding: "utf8", env: { ...process.env, - NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: createHash("sha256") - .update(fs.readFileSync(receiptFile)) - .digest("hex"), + NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: receiptSha256, + NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: trustedPolicyResultSha256, NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, NEMOCLAW_TEST_NODE_LOG: nodeLog, @@ -237,31 +325,70 @@ describe("reviewed npm audit handoff", () => { }); fs.writeFileSync(transportRawReport, "{}\n"); - const rejected = runHelper(); - expect(rejected.status).not.toBe(0); - expect(rejected.stderr).toContain("receipt rawResponseSha256 does not match"); + expect(JSON.parse(fs.readFileSync(producerPolicyResult, "utf8"))).toMatchObject({ + graph: "mcporter-runtime", + status: "clean", + }); + const rejectedByTrustedPolicy = spawnSync(process.execPath, verifierArgs, { + encoding: "utf8", + }); + expect(rejectedByTrustedPolicy.status).not.toBe(0); + expect(rejectedByTrustedPolicy.stderr).toContain( + "receipt rawResponseSha256 does not match", + ); + expect(fs.existsSync(trustedPolicyResult)).toBe(false); + + fs.writeFileSync(transportRawReport, rawReport); + const acceptedByTrustedPolicy = spawnSync(process.execPath, verifierArgs, { + encoding: "utf8", + }); + expect(acceptedByTrustedPolicy.status, acceptedByTrustedPolicy.stderr).toBe(0); + expect(JSON.parse(fs.readFileSync(trustedPolicyResult, "utf8"))).toMatchObject({ + graph: "mcporter-runtime", + status: "clean", + }); + + const wrongHash = "0".repeat(64); + expect(wrongHash).not.toBe(correctReceiptSha256); + const rejectedTransport = runHelper(wrongHash); + expect(rejectedTransport.status).not.toBe(0); + expect(rejectedTransport.stderr).toContain("receipt hash does not match"); expect(fs.existsSync(retainedReport)).toBe(false); expect(fs.existsSync(retainedResult)).toBe(false); + expect(fs.existsSync(nodeLog)).toBe(false); + + const verifiedPolicyResult = fs.readFileSync(trustedPolicyResult); + const verifiedPolicyResultSha256 = policyResultSha256(); + fs.writeFileSync(trustedPolicyResult, '{"graph":"mcporter-runtime","status":"failed"}\n'); + const rejectedPolicyResult = runHelper( + correctReceiptSha256, + verifiedPolicyResultSha256, + ); + expect(rejectedPolicyResult.status).not.toBe(0); + expect(rejectedPolicyResult.stderr).toContain( + "policy result hash does not match", + ); + expect(fs.existsSync(retainedReport)).toBe(false); + expect(fs.existsSync(retainedResult)).toBe(false); + expect(fs.existsSync(nodeLog)).toBe(false); + fs.writeFileSync(trustedPolicyResult, verifiedPolicyResult); - fs.writeFileSync(transportRawReport, rawReport); const accepted = runHelper(); expect(accepted.status, accepted.stderr).toBe(0); expect(fs.readFileSync(transportRawReport, "utf8")).toBe(rawReport); expect(fs.readFileSync(retainedReport, "utf8")).toBe(rawReport); - expect(JSON.parse(fs.readFileSync(retainedResult, "utf8"))).toMatchObject({ - graph: "mcporter-runtime", - status: "clean", - }); - expect(fs.readFileSync(nodeLog, "utf8").trim().split("\n").at(-1)).toBe( - verifierArgs.join(" "), + expect(fs.readFileSync(retainedResult, "utf8")).toBe( + fs.readFileSync(trustedPolicyResult, "utf8"), ); + expect(fs.existsSync(nodeLog)).toBe(false); const directHelper = path.join(root, "verify-mcporter-direct-audit.sh"); fs.writeFileSync( directHelper, helperSource .replaceAll(receiptFile, path.join(root, "missing-direct-receipt")) - .replaceAll(transportRawReport, path.join(root, "missing-direct-report")), + .replaceAll(transportRawReport, path.join(root, "missing-direct-report")) + .replaceAll(trustedPolicyResult, path.join(root, "missing-direct-policy-result")), { mode: 0o755 }, ); const direct = spawnSync("bash", [directHelper], { @@ -269,6 +396,7 @@ describe("reviewed npm audit handoff", () => { env: { ...process.env, NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: "", + NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: "", NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, NEMOCLAW_TEST_NODE_LOG: nodeLog, @@ -294,12 +422,17 @@ describe("reviewed npm audit handoff", () => { helperSource .replaceAll(receiptFile, path.join(root, "missing-secret-receipt")) .replaceAll(transportRawReport, path.join(root, "missing-secret-report")) + .replaceAll(trustedPolicyResult, path.join(root, "missing-secret-policy-result")) .replaceAll(path.join(root, "no-seed"), seedEvidence), { mode: 0o755 }, ); const rejectedSeed = spawnSync("bash", [seedHelper], { encoding: "utf8", - env: { ...process.env, NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: "" }, + env: { + ...process.env, + NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: "", + NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: "", + }, }); expect(rejectedSeed.status).not.toBe(0); expect(rejectedSeed.stderr).toContain("build-context mcporter audit evidence is not trusted"); diff --git a/test/inference/managed/managed-image-publication-workflow.test.ts b/test/inference/managed/managed-image-publication-workflow.test.ts index d91cfe5d78d..6e61533ff8e 100644 --- a/test/inference/managed/managed-image-publication-workflow.test.ts +++ b/test/inference/managed/managed-image-publication-workflow.test.ts @@ -1155,9 +1155,12 @@ fi "Prepare same-run mcporter audit evidence", "mcporter-runtime.receipt.json", "mcporter-runtime.raw.json", + "mcporter-runtime.policy.json", "nemoclaw-mcporter-audit-receipt", "nemoclaw-mcporter-audit-raw-report", + "nemoclaw-mcporter-audit-policy-result", "NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256", + "NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256", ].filter((marker) => !source.includes(marker)), ).toEqual([]); expect(source).not.toContain("NEMOCLAW_MCPORTER_AUDIT_RAW_REPORT_SHA256"); @@ -1166,6 +1169,7 @@ fi actionSource.includes('"secret-files":{"description"'), actionSource.includes('"secret-files":"${{ inputs.secret-files }}"'), ]).toEqual([true, true]); + }); it("holds every alias behind the exact six-candidate aggregate barrier (#7744)", () => { const workflow = readWorkflow("managed-images.yaml"); diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index 2ca4509f896..6ca076d7667 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -103,7 +103,15 @@ esac set -euo pipefail printf '%s\n' "$*" >>"$NEMOCLAW_TEST_SEED_LOG" if [[ "$*" == *"/scripts/lib/npm-audit-receipt.mts"* ]]; then - exit "$NEMOCLAW_TEST_RECEIPT_VERIFY_STATUS" + status="$NEMOCLAW_TEST_RECEIPT_VERIFY_STATUS" + result="" + while (($# > 0)); do + if [[ "$1" == "--result" ]]; then result="$2"; shift 2; else shift; fi + done + if [[ "$status" == 0 && -n "$result" ]]; then + printf '{"status":"clean"}\n' >"$result" + fi + exit "$status" fi mode="$4" shift 4 @@ -228,6 +236,14 @@ function recordedBuildInvocation(agent: string): string { return invocation!; } +function expectSingleTargetArch(agent: string, architecture: string): void { + expect( + recordedBuildInvocation(agent) + .split(" ") + .filter((argument) => argument === `TARGETARCH=${architecture}`), + ).toHaveLength(1); +} + function runBuild(sourceRoot: string, extraArgs: readonly string[] = [], platform = "linux/amd64") { const output = path.join(testRoot, "contracts.json"); return spawnSync( @@ -340,15 +356,13 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocation("openclaw")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); + expectSingleTargetArch("openclaw", "arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); + expectSingleTargetArch("hermes", "arm64"); expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( "--platform linux/arm64", ); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( - "--build-arg TARGETARCH=arm64", - ); + expectSingleTargetArch("langchain-deepagents-code", "arm64"); }); it("builds every agent without optional cache arguments", () => { @@ -380,15 +394,13 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocations()).toHaveLength(3); expect(recordedBuildInvocation("openclaw")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); + expectSingleTargetArch("openclaw", "arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); + expectSingleTargetArch("hermes", "arm64"); expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( "--platform linux/arm64", ); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( - "--build-arg TARGETARCH=arm64", - ); + expectSingleTargetArch("langchain-deepagents-code", "arm64"); }); it("passes each agent one empty absolute cache export root", () => { @@ -635,9 +647,15 @@ describe("protected managed-image build-cache boundary", () => { expect(recordedBuildInvocation("openclaw")).toContain( `--secret id=nemoclaw-mcporter-audit-raw-report,src=${realpathSync(auditRoot)}/mcporter-runtime.raw.json`, ); + expect(recordedBuildInvocation("openclaw")).toMatch( + /--secret id=nemoclaw-mcporter-audit-policy-result,src=\S+\/mcporter-runtime[.]policy[.]json/, + ); expect(recordedBuildInvocation("openclaw")).toContain( `--build-arg NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=${DIGEST}`, ); + expect(recordedBuildInvocation("openclaw")).toContain( + `--build-arg NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256=${DIGEST}`, + ); expect(recordedBuildInvocation("hermes").split(" ")).not.toContain("--no-cache"); expect(recordedBuildInvocation("langchain-deepagents-code").split(" ")).not.toContain( "--no-cache", diff --git a/test/runtime/sandbox/sandbox-build-context.test.ts b/test/runtime/sandbox/sandbox-build-context.test.ts index 3aed86f390f..e23c85f31a4 100644 --- a/test/runtime/sandbox/sandbox-build-context.test.ts +++ b/test/runtime/sandbox/sandbox-build-context.test.ts @@ -290,7 +290,6 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "lib", "bundled-npm-package.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "seed-reviewed-npm-cache.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "reviewed-npm-audit.mts"), "fixture\n", 0o700); - writeFixture(path.join("scripts", "lib", "npm-audit-receipt.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "openclaw-npm-remediation.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "verify-mcporter-audit.sh"), "fixture\n", 0o700); fs.chmodSync(path.join(sourceRoot, "scripts"), 0o700); diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index d1446d6afb2..81eccf78d80 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -71,19 +71,6 @@ function extractIntegrityGate(contents: string): string { .trim(); } -function extractAuditReceiptInvocation(contents: string): string { - const startMarker = "node --experimental-strip-types /scripts/lib/npm-audit-receipt.mts"; - const endMarker = "--legacy-npmjs true"; - const start = contents.indexOf(startMarker); - const end = contents.indexOf(endMarker, start); - expect(start).toBeGreaterThanOrEqual(0); - expect(end).toBeGreaterThan(start); - return contents - .slice(start, end + endMarker.length) - .replace(/\\\s*\n/g, " ") - .replace(/\s+/g, " "); -} - function runIntegrityGate(contents: string, version: string) { const script = [ "set -euo pipefail", @@ -195,7 +182,6 @@ describe("mcporter image supply-chain controls", () => { it.each(dockerfiles)("audits the committed dependency graph in $name", ({ contents }) => { const auditContents = `${contents}\n${mcporterAuditHelper}`; const flattenedContents = auditContents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); - const auditReceiptInvocation = extractAuditReceiptInvocation(auditContents); expect(contents).toContain( "COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/", ); @@ -211,26 +197,21 @@ describe("mcporter image supply-chain controls", () => { "node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", ); expect(contents).toContain("ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256="); + expect(contents).toContain("ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256="); expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false", ); expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false", ); - expect(flattenedContents).toContain( - "node --experimental-strip-types /scripts/lib/npm-audit-receipt.mts --receipt", - ); - expect(flattenedContents).toContain( - "--package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json --raw-report", - ); - expect(auditReceiptInvocation).toContain( - "--exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json --registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true", + expect(contents).toContain( + "--mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false", ); expect(expectedReviewedNpmVersion).toMatch(/^[0-9]+\.[0-9]+\.[0-9]+$/); - expect(auditReceiptInvocation).not.toContain("--npm-version"); + expect(auditContents).not.toContain("/scripts/lib/npm-audit-receipt.mts"); + expect(auditContents).toContain("sha256sum --check --status"); + expect(auditContents).toContain("policy_result_sha256"); expect(auditContents).not.toContain("--raw-copy"); - expect(auditReceiptInvocation).not.toMatch(/\bnpm\s+--version\b/); - expect(auditReceiptInvocation).not.toMatch(/\$\(|`/); expect(contents).not.toContain(`${runtimePrefix} audit --omit=dev --audit-level=low`); expect(contents).not.toContain(`${runtimePrefix} audit signatures`); expect(flattenedContents).toContain( From 1f9c0bb89d8365643b1a5cb9e90701628eae680c Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:35:03 -0700 Subject: [PATCH 38/56] fix(ci): refresh plugin cache seed identity --- tools/mcp-tool-discovery-runtime/npm-cache-seed/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/mcp-tool-discovery-runtime/npm-cache-seed/manifest.json b/tools/mcp-tool-discovery-runtime/npm-cache-seed/manifest.json index dedea62f93f..3ab4669579c 100644 --- a/tools/mcp-tool-discovery-runtime/npm-cache-seed/manifest.json +++ b/tools/mcp-tool-discovery-runtime/npm-cache-seed/manifest.json @@ -513,7 +513,7 @@ } ], "kind": "nemoclaw-locked-npm-cache-seed-v1", - "lockSha256": "66bef669196bb1c61385871e369542d3c321c277adb0f0e2e9f0ad972106b163", + "lockSha256": "55a512d782f8a4ad0eac39078b0e652400bf8580767b3a9ac5282a05bae47042", "target": { "cpu": "x64", "libc": "glibc", From d92eff45436e2e310acae1d0d3672640fb3c216a Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:17:33 -0700 Subject: [PATCH 39/56] fix(audit): retry empty transport responses --- scripts/lib/reviewed-npm-audit.mts | 10 +++++++++- test/automation/releases/reviewed-npm-audit.test.ts | 8 ++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index 3280b2603d0..a45aa69fee9 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -377,7 +377,15 @@ export function classifyNpmAuditResponse(result: { stderr: string; stdout: string; }): NpmAuditResponseClassification { - if (!result.stdout.trim()) return rejectedAuditResponse(result, "empty-output", false); + if (!result.stdout.trim()) { + const transport = retryableTransportCode({}, result.stderr); + return rejectedAuditResponse( + result, + transport ? "registry-network-error" : "empty-output", + transport !== undefined, + transport ? [`transport=${transport}`] : [], + ); + } let value: unknown; try { diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index 695df71905f..2c3ce38051f 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -224,19 +224,19 @@ describe("reviewed npm audit gate", () => { }); }); - it("retries the observed registry lookup failure with bounded backoff", () => { + it("retries an empty registry lookup response with bounded backoff", () => { const completeReport = { metadata: { vulnerabilities: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, }, }; const sensitiveStderr = - "request failed for https://audit-user:secret-token@registry.example/\n\u001b[31mstderr detail"; + "request failed with EAI_AGAIN for https://audit-user:secret-token@registry.example/\n\u001b[31mstderr detail"; const responses = [ { status: 1, stderr: sensitiveStderr, - stdout: JSON.stringify({ message: "connect ECONNREFUSED", error: { summary: "" } }), + stdout: "", }, { status: 0, stderr: "", stdout: JSON.stringify(completeReport) }, ]; @@ -254,7 +254,7 @@ describe("reviewed npm audit gate", () => { expect(delays).toEqual([1_000]); expect(warnings).toEqual([ expect.stringMatching( - /^npm audit scan failed on attempt 1\/2; retrying in 1000 ms \(reason=registry-network-error; exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=ECONNREFUSED required-field=metadata:missing\)$/, + /^npm audit scan failed on attempt 1\/2; retrying in 1000 ms \(reason=registry-network-error; exit=1 stdout-bytes=0 stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=EAI_AGAIN\)$/, ), ]); const warningOutput = warnings.join("\n"); From 47708edb61b329b0ccff01b0c08c338f7c15de3e Mon Sep 17 00:00:00 2001 From: Rebecca Sliter Date: Wed, 9 Sep 2026 11:58:44 -0700 Subject: [PATCH 40/56] test(security): isolate forged audit result Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../releases/reviewed-npm-audit-handoff.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 2fecaac1337..5c967242614 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -309,8 +309,9 @@ describe("reviewed npm audit handoff", () => { const runHelper = ( receiptSha256 = correctReceiptSha256, trustedPolicyResultSha256 = policyResultSha256(), + helperFile = helper, ) => - spawnSync("bash", [helper], { + spawnSync("bash", [helperFile], { encoding: "utf8", env: { ...process.env, @@ -357,12 +358,19 @@ describe("reviewed npm audit handoff", () => { expect(fs.existsSync(retainedResult)).toBe(false); expect(fs.existsSync(nodeLog)).toBe(false); - const verifiedPolicyResult = fs.readFileSync(trustedPolicyResult); const verifiedPolicyResultSha256 = policyResultSha256(); - fs.writeFileSync(trustedPolicyResult, '{"graph":"mcporter-runtime","status":"failed"}\n'); + const forgedPolicyResult = path.join(root, "forged-policy-result.json"); + const forgedPolicyHelper = path.join(root, "verify-forged-mcporter-audit.sh"); + fs.writeFileSync(forgedPolicyResult, '{"graph":"mcporter-runtime","status":"failed"}\n'); + fs.writeFileSync( + forgedPolicyHelper, + helperSource.replaceAll(trustedPolicyResult, forgedPolicyResult), + { mode: 0o755 }, + ); const rejectedPolicyResult = runHelper( correctReceiptSha256, verifiedPolicyResultSha256, + forgedPolicyHelper, ); expect(rejectedPolicyResult.status).not.toBe(0); expect(rejectedPolicyResult.stderr).toContain( @@ -371,7 +379,6 @@ describe("reviewed npm audit handoff", () => { expect(fs.existsSync(retainedReport)).toBe(false); expect(fs.existsSync(retainedResult)).toBe(false); expect(fs.existsSync(nodeLog)).toBe(false); - fs.writeFileSync(trustedPolicyResult, verifiedPolicyResult); const accepted = runHelper(); expect(accepted.status, accepted.stderr).toBe(0); From fbc43122ad78640e62363cdcd99768554301a790 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter Date: Wed, 9 Sep 2026 12:30:03 -0700 Subject: [PATCH 41/56] merge: prepare trusted conflict resolution Align the five conflicted paths with current main so GitHub can create verified merge ancestry before the reviewed resolution is reapplied. Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- Dockerfile | 92 +++-- Dockerfile.base | 49 ++- .../reviewed-npm-audit-handoff.test.ts | 353 +++--------------- ...otected-managed-image-build-script.test.ts | 153 +------- test/security/mcporter-supply-chain.test.ts | 62 +-- 5 files changed, 185 insertions(+), 524 deletions(-) diff --git a/Dockerfile b/Dockerfile index d9b6ebed92a..46bfe54c396 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ RUN --network=default /opt/nemoclaw-build-tools/npm-ci-locked.sh \ COPY nemoclaw/src/ /opt/nemoclaw/src/ COPY scripts/checks/verify-openshell-policy-boundary-dependencies.mts /opt/nemoclaw-build-checks/ RUN npm run build \ - && node --experimental-strip-types \ + && node \ /opt/nemoclaw-build-checks/verify-openshell-policy-boundary-dependencies.mts \ /opt/nemoclaw/dist/shared/openshell-policy-boundary.cjs @@ -168,7 +168,7 @@ COPY scripts/checks/materialize-locked-npm-cache-seed.mts /opt/checks/ COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/seed-reviewed-npm-cache.mts /opt/nemoclaw-build-tools/ COPY --from=wechat-npm-archives / /opt/wechat-npm-archives/ RUN --network=none install -d -o root -g root -m 0755 /out/wechat-npm-cache \ - && node --experimental-strip-types /opt/nemoclaw-build-tools/seed-reviewed-npm-cache.mts \ + && node /opt/nemoclaw-build-tools/seed-reviewed-npm-cache.mts \ --lockfile /opt/wechat-runtime/package-lock.json \ --cache /out/wechat-npm-cache \ --registry-origin https://registry.npmjs.org/ \ @@ -180,7 +180,7 @@ RUN --network=none install -d -o root -g root -m 0755 /out/wechat-npm-cache \ --userconfig /dev/null --registry https://registry.npmjs.org/ \ --cache /out/wechat-npm-cache \ && NPM_CONFIG_OFFLINE=true \ - node --experimental-strip-types /opt/nemoclaw-build-tools/reviewed-npm-archive.mts \ + node /opt/nemoclaw-build-tools/reviewed-npm-archive.mts \ --lockfile /opt/wechat-runtime/package-lock.json \ --cache /out/wechat-npm-cache \ --registry-origin https://registry.npmjs.org/ \ @@ -506,7 +506,7 @@ RUN --network=none set -eu; \ *) echo "ERROR: unsupported managed messaging npm target: $TARGETARCH" >&2; exit 1 ;; \ esac; \ install -d -o root -g root -m 0755 /out/npm-cache; \ - node --experimental-strip-types /opt/nemoclaw-build-tools/lib/seed-reviewed-npm-cache.mts \ + node /opt/nemoclaw-build-tools/lib/seed-reviewed-npm-cache.mts \ --lockfile /opt/managed-image-messaging-runtime/package-lock.json \ --cache /out/npm-cache \ --registry-origin https://registry.npmjs.org/ \ @@ -516,7 +516,7 @@ RUN --network=none set -eu; \ --ignore-scripts --omit=dev --legacy-peer-deps \ --userconfig /dev/null --registry https://registry.npmjs.org/ \ --cache /out/npm-cache; \ - node --experimental-strip-types /opt/nemoclaw-build-tools/lib/seed-reviewed-npm-cache.mts \ + node /opt/nemoclaw-build-tools/lib/seed-reviewed-npm-cache.mts \ --packuments-only \ --lockfile /opt/managed-image-messaging-runtime/package-lock.json \ --cache /out/npm-cache \ @@ -544,8 +544,8 @@ COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/ COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts +COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts -COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -634,7 +634,6 @@ ARG MCPORTER_VERSION=0.7.3 ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA== ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256= -ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256= # A cross-stage root copy is accepted by Docker's legacy builder and creates one # final-image layer while preserving metadata on existing parent directories. @@ -687,7 +686,7 @@ RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ fi; \ - node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ + node /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm # Reassert the npm-private brace-expansion fix for the final filesystem. @@ -696,7 +695,7 @@ RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ fi; \ - node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ + node /scripts/patch-bundled-npm-brace-expansion.mts \ --npm-root /usr/local/lib/node_modules/npm # Reassert the npm-private ip-address fix for the final filesystem. When @@ -706,7 +705,7 @@ RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ fi; \ - node --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts \ + node /scripts/lib/patch-bundled-npm-ip-address.mts \ --npm-root /usr/local/lib/node_modules/npm # Harden: remove unnecessary build tools and network probes from base image (#830) @@ -818,9 +817,9 @@ RUN command -v codex-acp >/dev/null # OPENCLAW_VERSION is the NemoClaw runtime build target and must meet the blueprint minimum. # Reviewed archives retain registry and packed-byte SRI, basename, local-only install, and cleanup gates. # hadolint ignore=DL3059,DL4006,DL3016,SC2015 -RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ +RUN --network=default \ + --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ - --mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false \ set -eu; \ if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ @@ -867,7 +866,7 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ [ -n "$MCPORTER_LOCK_SHA256" ] \ || { echo "ERROR: Could not hash the committed mcporter lockfile" >&2; exit 1; }; \ MCPORTER_AUDIT_POLICY_SHA256="$(sha256sum /scripts/npm-audit-exceptions.json | awk '{print $1}')"; \ - MCPORTER_EXPECTED_AUDIT_EXCEPTIONS="$(node --experimental-strip-types --input-type=module -e \ + MCPORTER_EXPECTED_AUDIT_EXCEPTIONS="$(node --input-type=module -e \ 'import fs from "node:fs"; import { parseAuditExceptionRegistry } from "/scripts/lib/reviewed-npm-audit.mts"; const policy=parseAuditExceptionRegistry(fs.readFileSync("/scripts/npm-audit-exceptions.json", "utf-8")); const ids=policy.exceptions.filter((entry)=>entry.graph==="mcporter-runtime").map((entry)=>entry.advisory).sort(); process.stdout.write(ids.join(",") || "none");')"; \ MCPORTER_EXPECTED_AUDIT_STATUS=clean; \ if [ "$MCPORTER_EXPECTED_AUDIT_EXCEPTIONS" != "none" ]; then MCPORTER_EXPECTED_AUDIT_STATUS=accepted-exceptions; fi; \ @@ -923,7 +922,7 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ # files from surviving a same-version reinstall. rm -rf /usr/local/lib/node_modules/openclaw /usr/local/bin/openclaw; \ if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then \ - node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts --verify-lock \ + node /scripts/lib/reviewed-npm-archive.mts --verify-lock \ --lock-sha256 "$OPENCLAW_LOCK_SHA256" \ --lockfile /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json \ --registry-origin https://registry.npmjs.org/ \ @@ -932,7 +931,7 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ npm --prefix /usr/local/lib/nemoclaw/openclaw-runtime ci \ --ignore-scripts --omit=dev --no-audit --no-fund --no-progress \ --userconfig /dev/null --registry https://registry.npmjs.org/; \ - node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ + node /scripts/lib/reviewed-npm-archive.mts \ --verify-installed-lock --lock-sha256 "$OPENCLAW_LOCK_SHA256" \ --lockfile /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json \ --install-root /usr/local/lib/nemoclaw/openclaw-runtime \ @@ -942,13 +941,13 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ ln -s /usr/local/lib/nemoclaw/openclaw-runtime/node_modules/openclaw /usr/local/lib/node_modules/openclaw; \ ln -s /usr/local/lib/nemoclaw/openclaw-runtime/node_modules/.bin/openclaw /usr/local/bin/openclaw; \ else \ - OPENCLAW_SOURCE_PACK_PATH="$(node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ + OPENCLAW_SOURCE_PACK_PATH="$(node /scripts/lib/reviewed-npm-archive.mts \ --package-spec "openclaw@${OPENCLAW_VERSION}" --integrity "$EXPECTED_INTEGRITY" \ --tarball-url "$EXPECTED_TARBALL" --label "OpenClaw ${OPENCLAW_VERSION}")"; \ OPENCLAW_PACK_PATH="$OPENCLAW_SOURCE_PACK_PATH"; \ OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_PACK_PATH")"; \ if [ "$OPENCLAW_VERSION" = "2026.3.11" ]; then \ - OPENCLAW_REMEDIATION_JSON="$(node --experimental-strip-types /scripts/lib/openclaw-npm-remediation.mts \ + OPENCLAW_REMEDIATION_JSON="$(node /scripts/lib/openclaw-npm-remediation.mts \ --archive "$OPENCLAW_SOURCE_PACK_PATH" --package-spec "openclaw@${OPENCLAW_VERSION}" \ --working-directory "$OPENCLAW_PACK_DIR")"; \ OPENCLAW_PACK_PATH="$(node -e 'const value = JSON.parse(process.argv[1]); if (!value.remediated || typeof value.archivePath !== "string") process.exit(1); process.stdout.write(value.archivePath)' "$OPENCLAW_REMEDIATION_JSON")"; \ @@ -968,7 +967,7 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ if [ "$USE_REVIEWED_BASE_RUNTIME" = "1" ]; then \ echo "INFO: Reusing reviewed base mcporter $CUR_MCPORTER_VER with matching lock provenance"; \ else \ - node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts --verify-only \ + node /scripts/lib/reviewed-npm-archive.mts --verify-only \ --package-spec "mcporter@${MCPORTER_VERSION}" --integrity "$MCPORTER_EXPECTED_INTEGRITY" \ --tarball-url "$MCPORTER_EXPECTED_TARBALL" --label "mcporter ${MCPORTER_VERSION}"; \ # Reinstall from the committed lock when matching protected base provenance @@ -984,7 +983,24 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter; \ test "$(mcporter --version)" = "$MCPORTER_VERSION"; \ fi; \ - bash /scripts/lib/verify-mcporter-audit.sh + MCPORTER_RECEIPT=/run/secrets/nemoclaw-mcporter-audit-receipt; \ + MCPORTER_RAW_REPORT=/run/secrets/nemoclaw-mcporter-audit-raw-report; \ + if [ -f "$MCPORTER_RECEIPT" ] || [ -f "$MCPORTER_RAW_REPORT" ] || [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]; then \ + [ -f "$MCPORTER_RECEIPT" ] && [ -f "$MCPORTER_RAW_REPORT" ] && printf %s "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}' \ + || { echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2; exit 1; }; \ + printf '%s %s\n' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" "$MCPORTER_RECEIPT" | sha256sum -c -; \ +node /scripts/lib/npm-audit-receipt.mts \ +--receipt "$MCPORTER_RECEIPT" \ +--package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ +--package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ +--raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ +--graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ +--registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true; \ + else \ + node /scripts/lib/reviewed-npm-audit.mts \ + --directory /usr/local/lib/nemoclaw/mcporter-runtime \ + --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high; \ + fi # Patch OpenClaw media fetch for proxy-only sandbox (NVIDIA/NemoClaw#1755). # @@ -1346,7 +1362,7 @@ RUN set -eu; \ # Removal criteria: drop when upstream OpenClaw fixes openclaw/openclaw#70164 # and openclaw/openclaw#50298, or when NemoClaw no longer ships an affected OpenClaw. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-chat-send.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-chat-send.mts \ /usr/local/lib/node_modules/openclaw/dist # Keep OpenClaw 2026.7.1 scope-upgrade approvals inside the gateway's @@ -1361,7 +1377,7 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-chat- # Removal criteria: drop when upstream OpenClaw can approve the same bounded # self-upgrade through the gateway using only operator.pairing. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-device-self-approval.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-device-self-approval.mts \ /usr/local/lib/node_modules/openclaw/dist # Keep backend RPC initiated by the OpenClaw gateway daemon on loopback while @@ -1376,7 +1392,7 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-devic # gateway URL. # hadolint ignore=DL3059 RUN if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then \ - node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-gateway-daemon-dialback.mts \ + node /usr/local/lib/nemoclaw/patch-openclaw-gateway-daemon-dialback.mts \ /usr/local/lib/node_modules/openclaw/dist; \ fi @@ -1392,7 +1408,7 @@ RUN if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then \ # Removal criteria: drop when upstream OpenClaw emits these structured fields # from its assistant error formatter for unreachable inference failures. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-issue-4434-diagnostics.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-issue-4434-diagnostics.mts \ /usr/local/lib/node_modules/openclaw/dist # Patch OpenClaw's MCP stdio launcher so npx-backed MCP servers run with -y. @@ -1402,7 +1418,7 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-issue # Removal criteria: drop when upstream OpenClaw normalizes npx MCP server args # and emits actionable MCP startup timeout diagnostics. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-mcp-npx.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-mcp-npx.mts \ /usr/local/lib/node_modules/openclaw/dist # Recover from a transient remote Streamable HTTP MCP startup failure. OpenClaw @@ -1416,7 +1432,7 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-mcp-n # Removal criterion: drop when upstream OpenClaw provides bounded startup retry, # negative-catalog invalidation, and temporary-transport failure attribution. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-mcp-reliability.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-mcp-reliability.mts \ /usr/local/lib/node_modules/openclaw/dist # Keep OpenClaw's 1,500 ms tools/list catalog timeout by default. A validated @@ -1427,7 +1443,7 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-mcp-r # Removal criterion: drop when upstream OpenClaw exposes an equivalent bounded # tools/list-only runtime setting. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-mcp-tools-list-timeout.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-mcp-tools-list-timeout.mts \ /usr/local/lib/node_modules/openclaw/dist # Emit a redacted managed-transport diagnostic when a remote Streamable HTTP MCP @@ -1443,14 +1459,14 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-mcp-t # Removal criterion: drop when upstream OpenClaw emits phase-classified, # redacted transport diagnostics for remote MCP fetch failures. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-managed-transport-diagnostics.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-managed-transport-diagnostics.mts \ /usr/local/lib/node_modules/openclaw/dist # Run the compact tool catalog shim for OpenClaw selection runtimes that still # need it. OpenClaw 2026.7.1 ships a built-in catalog surface, so the script # skips cleanly after classifying the compiled selection-*.js shape. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts \ /usr/local/lib/node_modules/openclaw/dist # OpenClaw 2026.7.1 moved gateway startup work into shared and per-agent SQLite @@ -1468,7 +1484,7 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-tool- # group-shared state databases and split-user cache migrations without # startup warnings. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-shared-state-permissions.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-shared-state-permissions.mts \ /usr/local/lib/node_modules/openclaw/dist # Set up blueprint for local resolution. @@ -1667,7 +1683,7 @@ USER sandbox # block until after build-time OpenClaw doctor/plugin commands complete. RUN NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=0 \ NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 \ - node --experimental-strip-types /scripts/generate-openclaw-config.mts + node /scripts/generate-openclaw-config.mts # Validate the patched OpenClaw tool-search contract against real generated # configs for both supported disclosure modes. This runs at image build time so @@ -1685,8 +1701,8 @@ RUN set -eu; \ NEMOCLAW_TOOL_DISCLOSURE="$mode" \ NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=0 \ NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 \ - node --experimental-strip-types /scripts/generate-openclaw-config.mts; \ - node --experimental-strip-types /scripts/validate-openclaw-tool-search.mts \ + node /scripts/generate-openclaw-config.mts; \ + node /scripts/validate-openclaw-tool-search.mts \ /usr/local/lib/node_modules/openclaw/dist \ "$validation_home/.openclaw/openclaw.json" \ "$mode" \ @@ -1724,7 +1740,7 @@ RUN --network=none --mount=from=openclaw-optional-plugin-archives,target=/opt/ne "$plugin_archive" "$expected_integrity"; \ printf '%s\n' "$plugin_archive"; \ else \ - node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ + node /scripts/lib/reviewed-npm-archive.mts \ --package-spec "$plugin_spec" --integrity "$expected_integrity" \ --tarball-url "$expected_tarball" --label "OpenClaw plugin ${plugin_spec}"; \ fi; \ @@ -1737,7 +1753,7 @@ RUN --network=none --mount=from=openclaw-optional-plugin-archives,target=/opt/ne plugin_install_archive="$plugin_archive"; \ case "$plugin_spec" in \ "@openclaw/diagnostics-otel@2026.7.1") \ - remediation_json="$(node --experimental-strip-types /scripts/lib/openclaw-npm-remediation.mts \ + remediation_json="$(node /scripts/lib/openclaw-npm-remediation.mts \ --archive "$plugin_archive" --package-spec "$plugin_spec" \ --working-directory "$plugin_work_root")"; \ plugin_install_archive="$(node -e 'const value = JSON.parse(process.argv[1]); if (!value.remediated || typeof value.archivePath !== "string") process.exit(1); process.stdout.write(value.archivePath)' "$remediation_json")" \ @@ -1792,7 +1808,7 @@ RUN chmod 755 /src/lib/messaging/applier/build/messaging-build-applier.mts \ # forwards explicit runtime env, so nemoclaw-start reads this generic artifact # when the env plan is absent. # hadolint ignore=DL3059 -RUN OPENCLAW_VERSION="${OPENCLAW_VERSION}" node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase runtime-setup +RUN OPENCLAW_VERSION="${OPENCLAW_VERSION}" node /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase runtime-setup USER sandbox # npm still needs a writable _cacache/tmp while OpenClaw packs the verified archive, @@ -1827,7 +1843,7 @@ RUN --mount=from=openclaw-managed-messaging-npm-cache,source=/out/npm-cache,targ fi; \ NEMOCLAW_WECHAT_NPM_INSTALL_CACHE="$install_cache" \ OPENCLAW_VERSION="${OPENCLAW_VERSION}" \ - node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts \ + node /src/lib/messaging/applier/build/messaging-build-applier.mts \ --agent openclaw --phase "$messaging_phase"; \ rm -rf "$install_cache"; \ trap - EXIT; \ @@ -1968,7 +1984,7 @@ RUN NPM_CONFIG_IGNORE_SCRIPTS=true npm_config_ignore_scripts=true \ # Apply messaging render and post-agent-install build-file hooks after agent/plugin installation. # hadolint ignore=DL3059,DL4006 -RUN OPENCLAW_VERSION="${OPENCLAW_VERSION}" node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase post-agent-install +RUN OPENCLAW_VERSION="${OPENCLAW_VERSION}" node /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase post-agent-install # A managed image is a neutral capability carrier, not an all-channels-enabled # deployment. Regenerate after every optional plugin is installed so OpenClaw's @@ -1976,7 +1992,7 @@ RUN OPENCLAW_VERSION="${OPENCLAW_VERSION}" node --experimental-strip-types /src/ # Validate the generated file through the pinned OpenClaw CLI. # hadolint ignore=DL3059,DL4006,SC2016 RUN if [ "$NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION" = "1" ]; then \ - node --experimental-strip-types /scripts/generate-openclaw-config.mts; \ + node /scripts/generate-openclaw-config.mts; \ validation="$(openclaw config validate --json)"; \ node -e 'const result=JSON.parse(process.argv[1]); if (result.valid !== true) process.exit(1)' "$validation"; \ node -e 'const fs=require("node:fs"), path=require("node:path"); const config=JSON.parse(fs.readFileSync("/sandbox/.openclaw/openclaw.json", "utf8")); const root="/usr/local/lib/node_modules/openclaw/dist/extensions"; const bundled=fs.readdirSync(root, {withFileTypes:true}).filter((entry)=>entry.isDirectory()).map((entry)=>entry.name).flatMap((id)=>{ const packagePath=path.join(root, id, "package.json"); if (!fs.existsSync(packagePath)) return []; const packageManifest=JSON.parse(fs.readFileSync(packagePath, "utf8")); if (!packageManifest.openclaw?.channel?.id) return []; const pluginManifest=JSON.parse(fs.readFileSync(path.join(root, id, "openclaw.plugin.json"), "utf8")); return [{channelId:packageManifest.openclaw.channel.id, pluginId:pluginManifest.id}]; }); if (!bundled.some(({channelId})=>channelId === "imessage") || !bundled.some(({channelId})=>channelId === "telegram")) throw new Error(`unexpected bundled OpenClaw channel inventory: ${bundled.map(({channelId})=>channelId).join(",")}`); for (const {channelId, pluginId} of bundled) { if (config.plugins?.entries?.[pluginId]?.enabled !== false || config.channels?.[channelId]?.enabled !== false) throw new Error(`bundled OpenClaw channel is not neutral: ${channelId}`); }'; \ diff --git a/Dockerfile.base b/Dockerfile.base index d4087bd6d46..60fe775ddf4 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -413,7 +413,6 @@ ARG MCPORTER_VERSION=0.7.3 ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA== ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256= -ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256= # Keep paired runtime manifests and remediation helpers in grouped layers so # the published base retains its established image layout. COPY agents/openclaw/openclaw-runtime/package.json \ @@ -428,7 +427,7 @@ COPY scripts/lib/reviewed-npm-archive.mts \ scripts/lib/reviewed-npm-audit.mts \ scripts/lib/openclaw-npm-remediation.mts \ /scripts/lib/ -COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh +COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -436,31 +435,31 @@ COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts # npm 10.9.8 in the pinned Node 22 image bundles an affected node-tar copy. # Replace it before npm processes the reviewed npm archive. -RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ +RUN node /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm # Upgrade the complete private npm tree so its sigstore, brace-expansion, and # picomatch packages meet the reviewed security floors. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts \ +RUN node /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm # npm 11.18.0 restores affected tar 7.5.19. Replace it from the # registry- and SRI-verified 7.5.21 archive before any npm consumers run. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ +RUN node /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm # npm 11.18.0 contains brace-expansion 5.0.7. Replace only that private # package from the reviewed 5.0.9 archive after the complete npm upgrade. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ +RUN node /scripts/patch-bundled-npm-brace-expansion.mts \ --npm-root /usr/local/lib/node_modules/npm # npm 11.18.0 contains ip-address 10.2.0. Replace only that private package # with the reviewed 10.3.1 archive after the complete npm upgrade. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts \ +RUN node /scripts/lib/patch-bundled-npm-ip-address.mts \ --npm-root /usr/local/lib/node_modules/npm # Keep OpenClaw's jiti-generated source cache out of /tmp so provider marker @@ -478,7 +477,6 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/blueprint.yaml \ --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ - --mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false \ echo "$OPENCLAW_VERSION" | grep -qxE '[0-9]+(\.[0-9]+)*' \ || { echo "Error: OPENCLAW_VERSION='$OPENCLAW_VERSION' is invalid (expected e.g. 2026.3.11)."; exit 1; }; \ OPENCLAW_MIN_VERSION=$(grep -m 1 'min_openclaw_version' /tmp/blueprint.yaml | awk '{print $2}' | tr -d '"'); \ @@ -512,7 +510,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep ACTUAL_OPENCLAW_LOCK_SHA256="$(sha256sum /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json | awk '{print $1}')"; \ [ "$ACTUAL_OPENCLAW_LOCK_SHA256" = "$OPENCLAW_LOCK_SHA256" ] \ || { echo "Error: OpenClaw lock SHA-256 mismatch (expected $OPENCLAW_LOCK_SHA256, found $ACTUAL_OPENCLAW_LOCK_SHA256)"; exit 1; }; \ - node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts --verify-lock \ + node /scripts/lib/reviewed-npm-archive.mts --verify-lock \ --lock-sha256 "$OPENCLAW_LOCK_SHA256" \ --lockfile /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json \ --registry-origin https://registry.npmjs.org/ \ @@ -522,7 +520,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep npm --prefix /usr/local/lib/nemoclaw/openclaw-runtime ci \ --ignore-scripts --omit=dev --no-audit --no-fund --no-progress \ --userconfig /dev/null --registry https://registry.npmjs.org/; \ - node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ + node /scripts/lib/reviewed-npm-archive.mts \ --verify-installed-lock --lock-sha256 "$OPENCLAW_LOCK_SHA256" \ --lockfile /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json \ --install-root /usr/local/lib/nemoclaw/openclaw-runtime \ @@ -533,7 +531,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep ln -s /usr/local/lib/nemoclaw/openclaw-runtime/node_modules/.bin/openclaw /usr/local/bin/openclaw; \ OPENCLAW_RECIPE='locked-ci+reviewed-lifecycle-v2'; \ else \ - OPENCLAW_SOURCE_PACK_PATH="$(node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ + OPENCLAW_SOURCE_PACK_PATH="$(node /scripts/lib/reviewed-npm-archive.mts \ --package-spec "openclaw@${OPENCLAW_VERSION}" --integrity "$EXPECTED_INTEGRITY" \ --tarball-url "$EXPECTED_TARBALL" --label "OpenClaw ${OPENCLAW_VERSION}")"; \ if [ -z "$OPENCLAW_SOURCE_PACK_PATH" ] || [ ! -f "$OPENCLAW_SOURCE_PACK_PATH" ] || [ -L "$OPENCLAW_SOURCE_PACK_PATH" ]; then \ @@ -542,7 +540,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep OPENCLAW_PACK_PATH="$OPENCLAW_SOURCE_PACK_PATH"; \ OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_PACK_PATH")"; \ if [ "$OPENCLAW_VERSION" = "2026.3.11" ]; then \ - OPENCLAW_REMEDIATION_JSON="$(node --experimental-strip-types /scripts/lib/openclaw-npm-remediation.mts \ + OPENCLAW_REMEDIATION_JSON="$(node /scripts/lib/openclaw-npm-remediation.mts \ --archive "$OPENCLAW_SOURCE_PACK_PATH" --package-spec "openclaw@${OPENCLAW_VERSION}" \ --working-directory "$OPENCLAW_PACK_DIR")"; \ OPENCLAW_PACK_PATH="$(node -e 'const value = JSON.parse(process.argv[1]); if (!value.remediated || typeof value.archivePath !== "string") process.exit(1); process.stdout.write(value.archivePath)' "$OPENCLAW_REMEDIATION_JSON")"; \ @@ -569,7 +567,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep && if [ -z "$MCPORTER_EXPECTED_INTEGRITY" ]; then \ echo "ERROR: mcporter ${MCPORTER_VERSION} has no committed npm integrity pin" >&2; exit 1; \ fi \ - && node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts --verify-only \ + && node /scripts/lib/reviewed-npm-archive.mts --verify-only \ --package-spec "mcporter@${MCPORTER_VERSION}" --integrity "$MCPORTER_EXPECTED_INTEGRITY" \ --tarball-url "$MCPORTER_EXPECTED_TARBALL" --label "mcporter ${MCPORTER_VERSION}" \ && rm -rf /usr/local/lib/node_modules/mcporter /usr/local/bin/mcporter \ @@ -581,9 +579,28 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep 'const { StreamableHTTPServerTransport } = await import("file:///usr/local/lib/nemoclaw/mcporter-runtime/node_modules/@modelcontextprotocol/sdk/dist/esm/server/streamableHttp.js"); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await transport.close();' \ && ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter \ && test "$(mcporter --version)" = "$MCPORTER_VERSION" \ - && NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json \ - NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json \ - bash /scripts/lib/verify-mcporter-audit.sh \ + && MCPORTER_RECEIPT=/run/secrets/nemoclaw-mcporter-audit-receipt \ + && MCPORTER_RAW_REPORT=/run/secrets/nemoclaw-mcporter-audit-raw-report \ + && if [ -f "$MCPORTER_RECEIPT" ] || [ -f "$MCPORTER_RAW_REPORT" ] || [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]; then \ + [ -f "$MCPORTER_RECEIPT" ] && [ -f "$MCPORTER_RAW_REPORT" ] && printf %s "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}' \ + || { echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2; exit 1; }; \ + printf '%s %s\n' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" "$MCPORTER_RECEIPT" | sha256sum -c -; \ + node /scripts/lib/npm-audit-receipt.mts \ + --receipt "$MCPORTER_RECEIPT" \ + --package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ + --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ + --raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ + --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ + --registry https://registry.yarnpkg.com --threshold high \ + --legacy-npmjs true \ + --result /tmp/mcporter-npm-audit-policy.json \ + && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json; \ + else \ + node /scripts/lib/reviewed-npm-audit.mts \ + --directory /usr/local/lib/nemoclaw/mcporter-runtime \ + --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high \ + --report /tmp/mcporter-npm-audit.json --result /tmp/mcporter-npm-audit-policy.json; \ + fi \ && MCPORTER_AUDIT_STATUS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').status")" \ && MCPORTER_AUDIT_EXCEPTIONS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').acceptedAdvisories.join(',') || 'none'")" \ && MCPORTER_AUDIT_POLICY_SHA256="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').exceptionPolicySha256")" \ diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 5c967242614..7e37b5d1882 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -25,9 +25,6 @@ type Workflow = { string, { readonly steps?: readonly { - readonly name?: string; - readonly run?: string; - readonly uses?: string; readonly with?: Readonly>; }[]; } @@ -77,7 +74,6 @@ describe("reviewed npm audit handoff", () => { const result = spawnSync( process.execPath, [ - "--experimental-strip-types", "--input-type=module", "--eval", "await import(process.argv[1])", @@ -93,143 +89,43 @@ describe("reviewed npm audit handoff", () => { }, ); - // source-shape-contract: security -- Every production image builder must keep the trusted three-file audit handoff atomic because GitHub and BuildKit consume these declarations directly. - it("pairs every production audit receipt with raw and trusted policy results", () => { - const managedWorkflow = YAML.parse( - fs.readFileSync(path.join(REPO_ROOT, ".github/workflows/managed-images.yaml"), "utf8"), - ) as Workflow; - const managedSteps = Object.values(managedWorkflow.jobs ?? {}).flatMap( - (job) => job.steps ?? [], - ); - const managedHandoffs = managedSteps - .map((step) => JSON.stringify(step)) - .filter((source) => source.includes("nemoclaw-mcporter-audit-receipt")); - const baseWorkflow = YAML.parse( - fs.readFileSync(path.join(REPO_ROOT, ".github/workflows/base-image-platform.yaml"), "utf8"), - ) as Workflow; - const baseHandoff = JSON.stringify( - baseWorkflow.jobs?.build?.steps?.find( - ({ name }) => name === "Build and publish platform digest", - ), - ); - const baseAction = YAML.parse( - fs.readFileSync( - path.join(REPO_ROOT, ".github/actions/build-base-image-platform/action.yaml"), - "utf8", - ), - ) as { - readonly runs?: { readonly steps?: readonly { readonly name?: string }[] }; - }; - const baseActionHandoff = JSON.stringify( - baseAction.runs?.steps?.find( - ({ name }) => name === "Build and push platform digest", - ), - ); - const baseActionValidation = JSON.stringify( - baseAction.runs?.steps?.find( - ({ name }) => name === "Validate production Docker build args", - ), - ); - const buildKitHandoffs = [...managedHandoffs, baseActionHandoff]; - - expect(managedHandoffs.length).toBeGreaterThan(0); - expect( - buildKitHandoffs.filter( - (source) => !source.includes("nemoclaw-mcporter-audit-receipt"), - ), - ).toEqual([]); - expect( - buildKitHandoffs.filter( - (source) => !source.includes("nemoclaw-mcporter-audit-raw-report"), - ), - ).toEqual([]); - expect( - buildKitHandoffs.filter( - (source) => !source.includes("nemoclaw-mcporter-audit-policy-result"), - ), - ).toEqual([]); - expect( - managedHandoffs.filter( - (source) => !source.includes("NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256"), - ), - ).toEqual([]); - expect(baseActionValidation).toContain( - "NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256", - ); - expect(baseHandoff).toContain("mcporter-audit-receipt"); - expect(baseHandoff).toContain("mcporter-audit-raw-report"); - expect(baseHandoff).toContain("mcporter-audit-policy-result"); - - const prPreparation = managedSteps.find( - ({ name, run }) => name === "Prepare same-run mcporter audit evidence" && run?.includes("trusted_root"), - ); - expect(prPreparation?.run).toContain('"$trusted_root/scripts/lib/npm-audit-receipt.mts"'); - expect(prPreparation?.run).toContain('--result "$policy"'); - expect(prPreparation?.run).not.toContain( - '"$GITHUB_WORKSPACE/scripts/lib/npm-audit-receipt.mts"', - ); - }); - - it("keeps protected audit acceptance under trusted policy and rejects forged transport", () => { - const root = fs.realpathSync( - fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")), - ); - const trustedRoot = path.join(root, "trusted"); - const targetRoot = path.join(root, "target"); - const runtime = path.join(targetRoot, "agents/openclaw/mcporter-runtime"); - const artifactDirectory = path.join(targetRoot, "artifacts/reviewed-npm-audit"); - const exceptionFile = path.join(trustedRoot, "ci/npm-audit-exceptions.json"); - const auditConfigFile = path.join(trustedRoot, "ci/reviewed-npm-audit.json"); - const auditConfig = JSON.parse( - fs.readFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), - ); - const npmVersion = auditConfig.npmVersion as string; + it("passes producer output to the Docker receipt verifier and rejects an npm mismatch", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")); + const packageJsonFile = path.join(root, "package.json"); + const packageLockFile = path.join(root, "package-lock.json"); + const rawReportFile = path.join(root, "report.json"); + const exceptionFile = path.join(root, "exceptions.json"); + const auditConfigFile = path.join(root, "reviewed-npm-audit.json"); + const resultFile = path.join(root, "policy.json"); + const packageJson = Buffer.from("temporary manifest\n"); + const packageLock = Buffer.from("temporary lock\n"); + const exceptionPolicy = '{"schemaVersion":1,"exceptions":[]}\n'; const rawReport = '{"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n'; try { - fs.mkdirSync(runtime, { recursive: true }); - fs.mkdirSync(path.join(trustedRoot, "ci"), { recursive: true }); - fs.cpSync(path.join(REPO_ROOT, "scripts"), path.join(trustedRoot, "scripts"), { - recursive: true, - }); - fs.cpSync(path.join(REPO_ROOT, "agents/openclaw/mcporter-runtime"), runtime, { - recursive: true, - }); - fs.mkdirSync(path.join(targetRoot, "ci"), { recursive: true }); - fs.mkdirSync(path.join(targetRoot, "scripts", "lib"), { recursive: true }); - fs.writeFileSync(path.join(targetRoot, "ci", "reviewed-npm-audit.json"), "{}\n"); - fs.writeFileSync( - path.join(targetRoot, "scripts", "audit-reviewed-npm-graph.mts"), - "throw new Error('candidate producer executed');\n", - ); - fs.writeFileSync( - path.join(targetRoot, "scripts", "lib", "npm-audit-receipt.mts"), - "throw new Error('candidate verifier executed');\n", - ); - fs.copyFileSync(path.join(REPO_ROOT, "ci/npm-audit-exceptions.json"), exceptionFile); - fs.copyFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), auditConfigFile); - fs.mkdirSync(artifactDirectory, { recursive: true }); - const rawReportFile = path.join(artifactDirectory, "audit.json"); + fs.writeFileSync(packageJsonFile, packageJson); + fs.writeFileSync(packageLockFile, packageLock); fs.writeFileSync(rawReportFile, rawReport); + fs.writeFileSync(exceptionFile, exceptionPolicy); + fs.writeFileSync(auditConfigFile, JSON.stringify({ npmVersion: "10.9.4" })); fs.writeFileSync( - path.join(artifactDirectory, "audit.provenance.json"), + path.join(root, "report.provenance.json"), JSON.stringify({ run: { startedAt: new Date().toISOString() } }), ); - emitAuditReceipt({ - artifactDirectory, - graphId: "mcporter-runtime", - npmVersion, - packageJsonFile: path.join(runtime, "package.json"), - packageLockFile: path.join(runtime, "package-lock.json"), + const receiptFile = emitAuditReceipt({ + artifactDirectory: root, + graphId: "temporary-graph", + npmVersion: "10.9.4", + packageJsonFile, + packageLockFile, + preserveInputs: true, rawReportFile, registryOrigin: "https://registry.yarnpkg.com", result: { acceptedAdvisories: [], blockingThreshold: "high", - exceptionPolicySha256: createHash("sha256") - .update(fs.readFileSync(exceptionFile)) - .digest("hex"), - graph: "mcporter-runtime", + exceptionPolicySha256: createHash("sha256").update(exceptionPolicy).digest("hex"), + graph: "temporary-graph", reported: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, schemaVersion: 1, status: "clean", @@ -238,21 +134,11 @@ describe("reviewed npm audit handoff", () => { threshold: "high", }); - const receiptFile = path.join(artifactDirectory, "mcporter-runtime.receipt.json"); - const retainedPackageJson = path.join(runtime, "package.json"); - const retainedPackageLock = path.join(runtime, "package-lock.json"); - const transportRawReport = path.join(artifactDirectory, "mcporter-runtime.raw.json"); - const producerPolicyResult = path.join( - artifactDirectory, - "mcporter-runtime.policy.json", - ); - const trustedPolicyResult = path.join(root, "trusted-policy-result.json"); - const receiptVerifier = path.join(trustedRoot, "scripts", "lib", "npm-audit-receipt.mts"); - const retainedReport = path.join(root, "retained-report.json"); - const retainedResult = path.join(root, "retained-result.json"); + const retainedPackageJson = path.join(root, "temporary-graph.package.json"); + const retainedPackageLock = path.join(root, "temporary-graph.package-lock.json"); + const transportRawReport = path.join(root, "temporary-graph.raw.json"); const verifierArgs = [ - "--experimental-strip-types", - receiptVerifier, + path.join(REPO_ROOT, "scripts", "lib", "npm-audit-receipt.mts"), "--receipt", receiptFile, "--package-json", @@ -264,185 +150,32 @@ describe("reviewed npm audit handoff", () => { "--exceptions", exceptionFile, "--graph", - "mcporter-runtime", + "temporary-graph", "--audit-config", auditConfigFile, "--registry", "https://registry.yarnpkg.com", "--threshold", "high", - "--legacy-npmjs", - "true", "--result", - trustedPolicyResult, + resultFile, ]; - const nodeLog = path.join(root, "node.log"); - const stubBin = path.join(root, "bin"); - const helper = path.join(root, "verify-mcporter-audit.sh"); - fs.mkdirSync(stubBin); - fs.writeFileSync( - path.join(stubBin, "node"), - '#!/usr/bin/env bash\nset -euo pipefail\nprintf \'%s\\n\' "$*" >>"$NEMOCLAW_TEST_NODE_LOG"\n[[ "$*" != *"/scripts/lib/reviewed-npm-audit.mts"* ]] || exit 0\nexec "$NEMOCLAW_TEST_REAL_NODE" "$@"\n', - { mode: 0o755 }, - ); - let helperSource = fs.readFileSync( - path.join(REPO_ROOT, "scripts/lib/verify-mcporter-audit.sh"), - "utf8", - ); - helperSource = helperSource - .replaceAll("/run/secrets/nemoclaw-mcporter-audit-receipt", receiptFile) - .replaceAll("/run/secrets/nemoclaw-mcporter-audit-raw-report", transportRawReport) - .replaceAll( - "/run/secrets/nemoclaw-mcporter-audit-policy-result", - trustedPolicyResult, - ) - .replaceAll( - "/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", - path.join(root, "no-seed"), - ); - fs.writeFileSync(helper, helperSource, { mode: 0o755 }); - const correctReceiptSha256 = createHash("sha256") - .update(fs.readFileSync(receiptFile)) - .digest("hex"); - const policyResultSha256 = () => - createHash("sha256").update(fs.readFileSync(trustedPolicyResult)).digest("hex"); - const runHelper = ( - receiptSha256 = correctReceiptSha256, - trustedPolicyResultSha256 = policyResultSha256(), - helperFile = helper, - ) => - spawnSync("bash", [helperFile], { - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: receiptSha256, - NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: trustedPolicyResultSha256, - NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, - NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, - NEMOCLAW_TEST_NODE_LOG: nodeLog, - NEMOCLAW_TEST_REAL_NODE: process.execPath, - PATH: `${stubBin}:${process.env.PATH ?? ""}`, - }, - }); - - fs.writeFileSync(transportRawReport, "{}\n"); - expect(JSON.parse(fs.readFileSync(producerPolicyResult, "utf8"))).toMatchObject({ - graph: "mcporter-runtime", - status: "clean", - }); - const rejectedByTrustedPolicy = spawnSync(process.execPath, verifierArgs, { - encoding: "utf8", - }); - expect(rejectedByTrustedPolicy.status).not.toBe(0); - expect(rejectedByTrustedPolicy.stderr).toContain( - "receipt rawResponseSha256 does not match", - ); - expect(fs.existsSync(trustedPolicyResult)).toBe(false); - - fs.writeFileSync(transportRawReport, rawReport); - const acceptedByTrustedPolicy = spawnSync(process.execPath, verifierArgs, { - encoding: "utf8", - }); - expect(acceptedByTrustedPolicy.status, acceptedByTrustedPolicy.stderr).toBe(0); - expect(JSON.parse(fs.readFileSync(trustedPolicyResult, "utf8"))).toMatchObject({ - graph: "mcporter-runtime", - status: "clean", - }); - - const wrongHash = "0".repeat(64); - expect(wrongHash).not.toBe(correctReceiptSha256); - const rejectedTransport = runHelper(wrongHash); - expect(rejectedTransport.status).not.toBe(0); - expect(rejectedTransport.stderr).toContain("receipt hash does not match"); - expect(fs.existsSync(retainedReport)).toBe(false); - expect(fs.existsSync(retainedResult)).toBe(false); - expect(fs.existsSync(nodeLog)).toBe(false); - - const verifiedPolicyResultSha256 = policyResultSha256(); - const forgedPolicyResult = path.join(root, "forged-policy-result.json"); - const forgedPolicyHelper = path.join(root, "verify-forged-mcporter-audit.sh"); - fs.writeFileSync(forgedPolicyResult, '{"graph":"mcporter-runtime","status":"failed"}\n'); - fs.writeFileSync( - forgedPolicyHelper, - helperSource.replaceAll(trustedPolicyResult, forgedPolicyResult), - { mode: 0o755 }, - ); - const rejectedPolicyResult = runHelper( - correctReceiptSha256, - verifiedPolicyResultSha256, - forgedPolicyHelper, - ); - expect(rejectedPolicyResult.status).not.toBe(0); - expect(rejectedPolicyResult.stderr).toContain( - "policy result hash does not match", - ); - expect(fs.existsSync(retainedReport)).toBe(false); - expect(fs.existsSync(retainedResult)).toBe(false); - expect(fs.existsSync(nodeLog)).toBe(false); - - const accepted = runHelper(); + const accepted = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); expect(accepted.status, accepted.stderr).toBe(0); + expect(fs.readFileSync(retainedPackageJson)).toEqual(packageJson); + expect(fs.readFileSync(retainedPackageLock)).toEqual(packageLock); expect(fs.readFileSync(transportRawReport, "utf8")).toBe(rawReport); - expect(fs.readFileSync(retainedReport, "utf8")).toBe(rawReport); - expect(fs.readFileSync(retainedResult, "utf8")).toBe( - fs.readFileSync(trustedPolicyResult, "utf8"), - ); - expect(fs.existsSync(nodeLog)).toBe(false); - - const directHelper = path.join(root, "verify-mcporter-direct-audit.sh"); - fs.writeFileSync( - directHelper, - helperSource - .replaceAll(receiptFile, path.join(root, "missing-direct-receipt")) - .replaceAll(transportRawReport, path.join(root, "missing-direct-report")) - .replaceAll(trustedPolicyResult, path.join(root, "missing-direct-policy-result")), - { mode: 0o755 }, - ); - const direct = spawnSync("bash", [directHelper], { - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: "", - NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: "", - NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, - NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, - NEMOCLAW_TEST_NODE_LOG: nodeLog, - PATH: `${stubBin}:${process.env.PATH ?? ""}`, - }, + expect(JSON.parse(fs.readFileSync(resultFile, "utf8"))).toMatchObject({ + graph: "temporary-graph", + status: "clean", }); - expect(direct.status, direct.stderr).toBe(0); - expect(fs.readFileSync(nodeLog, "utf8").trim().split("\n").at(-1)).toContain( - `--report ${retainedReport} --result ${retainedResult}`, - ); - const seedEvidence = path.join(root, "seed", "reviewed-npm-audit"); - const seedHelper = path.join(root, "verify-mcporter-seed-audit.sh"); - fs.mkdirSync(seedEvidence, { recursive: true }); - fs.copyFileSync(receiptFile, path.join(seedEvidence, "mcporter-runtime.receipt.json")); - fs.writeFileSync(path.join(seedEvidence, "mcporter-runtime.raw.json"), rawReport); - fs.writeFileSync( - path.join(seedEvidence, "mcporter-runtime.receipt.sha256"), - `${createHash("sha256").update(fs.readFileSync(receiptFile)).digest("hex")}\n`, - ); - fs.writeFileSync( - seedHelper, - helperSource - .replaceAll(receiptFile, path.join(root, "missing-secret-receipt")) - .replaceAll(transportRawReport, path.join(root, "missing-secret-report")) - .replaceAll(trustedPolicyResult, path.join(root, "missing-secret-policy-result")) - .replaceAll(path.join(root, "no-seed"), seedEvidence), - { mode: 0o755 }, - ); - const rejectedSeed = spawnSync("bash", [seedHelper], { - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: "", - NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: "", - }, - }); - expect(rejectedSeed.status).not.toBe(0); - expect(rejectedSeed.stderr).toContain("build-context mcporter audit evidence is not trusted"); + fs.rmSync(resultFile); + fs.writeFileSync(auditConfigFile, JSON.stringify({ npmVersion: "11.18.0" })); + const rejected = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); + expect(rejected.status).not.toBe(0); + expect(rejected.stderr).toContain("receipt identity does not match expected graph and npm"); + expect(fs.existsSync(resultFile)).toBe(false); } finally { fs.rmSync(root, { recursive: true, force: true }); } diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index 6ca076d7667..39ef180f88f 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -30,7 +30,6 @@ let stubBin = ""; let dockerLog = ""; let dockerBuildCount = ""; let dockerBuildFailureMode = ""; -let receiptVerifyStatus = ""; let seedLog = ""; let registryCurlExit = ""; let registryLog = ""; @@ -69,7 +68,7 @@ case "$*" in ;; npm-registry-dns-once:1 | npm-registry-dns-always:1 | npm-registry-dns-always:2) printf '%s\n' '#128 0.180 ERROR: curl failed: curl: (6) Could not resolve host: registry.npmjs.org' >&2 - printf '%s\n' 'ERROR: failed to build: failed to solve: process "/bin/sh -c node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts --npm-root /usr/local/lib/node_modules/npm" did not complete successfully: exit code: 1' >&2 + printf '%s\n' 'ERROR: failed to build: failed to solve: process "/bin/sh -c node /scripts/patch-bundled-npm-tar.mts --npm-root /usr/local/lib/node_modules/npm" did not complete successfully: exit code: 1' >&2 exit 42 ;; npm-registry-dns-near-match:1) @@ -102,19 +101,8 @@ esac `#!/usr/bin/env bash set -euo pipefail printf '%s\n' "$*" >>"$NEMOCLAW_TEST_SEED_LOG" -if [[ "$*" == *"/scripts/lib/npm-audit-receipt.mts"* ]]; then - status="$NEMOCLAW_TEST_RECEIPT_VERIFY_STATUS" - result="" - while (($# > 0)); do - if [[ "$1" == "--result" ]]; then result="$2"; shift 2; else shift; fi - done - if [[ "$status" == 0 && -n "$result" ]]; then - printf '{"status":"clean"}\n' >"$result" - fi - exit "$status" -fi -mode="$4" -shift 4 +mode="$3" +shift 3 output="" while (($# > 0)); do case "$1" in @@ -170,12 +158,6 @@ function completeImportedCache(cacheRoot: string): void { writeFileSync(path.join(cacheRoot, "messaging-npm-cache-seed", "manifest.json"), "{}\n", "utf8"); } -function completeAuditEvidence(auditDirectory: string): void { - mkdirSync(auditDirectory, { recursive: true }); - writeFileSync(path.join(auditDirectory, "mcporter-runtime.receipt.json"), '{"result":"pass"}\n'); - writeFileSync(path.join(auditDirectory, "mcporter-runtime.raw.json"), '{"metadata":{}}\n'); -} - function completeSourceBoundary(sourceRoot: string): void { mkdirSync(path.join(sourceRoot, "nemoclaw"), { recursive: true }); mkdirSync(path.join(sourceRoot, "scripts", "checks"), { recursive: true }); @@ -223,9 +205,7 @@ function completeSourceBoundary(sourceRoot: string): void { function recordedBuildInvocations(): string[] { return readFileSync(dockerLog, "utf8") .split("\n") - .filter( - (line) => line.startsWith("buildx build ") && line.includes("io.nvidia.nemoclaw.agent="), - ); + .filter((line) => line.startsWith("buildx build ")); } function recordedBuildInvocation(agent: string): string { @@ -236,15 +216,11 @@ function recordedBuildInvocation(agent: string): string { return invocation!; } -function expectSingleTargetArch(agent: string, architecture: string): void { - expect( - recordedBuildInvocation(agent) - .split(" ") - .filter((argument) => argument === `TARGETARCH=${architecture}`), - ).toHaveLength(1); -} - -function runBuild(sourceRoot: string, extraArgs: readonly string[] = [], platform = "linux/amd64") { +function runBuild( + sourceRoot: string, + extraArgs: readonly string[] = [], + platform = "linux/amd64", +) { const output = path.join(testRoot, "contracts.json"); return spawnSync( "bash", @@ -280,7 +256,6 @@ function runBuild(sourceRoot: string, extraArgs: readonly string[] = [], platfor NEMOCLAW_TEST_REGISTRY_LOG: registryLog, NEMOCLAW_TEST_REGISTRY_STATUS: registryStatus, NEMOCLAW_TEST_REAL_PATH: process.env.PATH ?? "", - NEMOCLAW_TEST_RECEIPT_VERIFY_STATUS: receiptVerifyStatus, NEMOCLAW_TEST_SEED_LOG: seedLog, NEMOCLAW_TEST_TEE_FAILURE_MODE: teeFailureMode, PATH: `${stubBin}:${process.env.PATH ?? ""}`, @@ -296,7 +271,6 @@ beforeEach(() => { dockerLog = path.join(testRoot, "docker.log"); dockerBuildCount = path.join(testRoot, "docker-build-count"); dockerBuildFailureMode = ""; - receiptVerifyStatus = "0"; seedLog = path.join(testRoot, "seed.log"); registryCurlExit = "0"; registryLog = path.join(testRoot, "registry.log"); @@ -356,13 +330,13 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocation("openclaw")).toContain("--platform linux/arm64"); - expectSingleTargetArch("openclaw", "arm64"); + expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); - expectSingleTargetArch("hermes", "arm64"); + expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--platform linux/arm64"); expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( - "--platform linux/arm64", + "--build-arg TARGETARCH=arm64", ); - expectSingleTargetArch("langchain-deepagents-code", "arm64"); }); it("builds every agent without optional cache arguments", () => { @@ -394,13 +368,11 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocations()).toHaveLength(3); expect(recordedBuildInvocation("openclaw")).toContain("--platform linux/arm64"); - expectSingleTargetArch("openclaw", "arm64"); + expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); - expectSingleTargetArch("hermes", "arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( - "--platform linux/arm64", - ); - expectSingleTargetArch("langchain-deepagents-code", "arm64"); + expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--platform linux/arm64"); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--build-arg TARGETARCH=arm64"); }); it("passes each agent one empty absolute cache export root", () => { @@ -412,7 +384,6 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(existsSync(cacheRoot)).toBe(true); expect(recordedBuildInvocations()).toHaveLength(3); - expect(existsSync(path.join(cacheRoot, "reviewed-npm-audit"))).toBe(false); expect({ openclaw: recordedBuildInvocation("openclaw"), @@ -446,27 +417,6 @@ describe("protected managed-image build-cache boundary", () => { expect(existsSync(path.join(cacheRoot, "messaging-npm-cache-seed", "manifest.json"))).toBe( true, ); - expect(recordedBuildInvocation("openclaw")).not.toContain("nemoclaw-mcporter-audit"); - expect(recordedBuildInvocation("hermes")).not.toContain("nemoclaw-mcporter-audit"); - expect(recordedBuildInvocation("langchain-deepagents-code")).not.toContain( - "nemoclaw-mcporter-audit", - ); - }); - - it("cleans an incomplete cache export after a protected build fails", () => { - const cacheRoot = path.join(testRoot, "export-cache"); - stubBuildInvocation(); - dockerBuildFailureMode = "near-match"; - - const failed = runBuild(REPO_ROOT, ["--cache-to", cacheRoot]); - - expect(failed.status, failed.stderr).toBe(42); - expect(readdirSync(cacheRoot)).toEqual([]); - - dockerBuildFailureMode = ""; - const retried = runBuild(REPO_ROOT, ["--cache-to", cacheRoot]); - - expect(retried.status, retried.stderr).toBe(0); }); it.each([ @@ -546,51 +496,6 @@ describe("protected managed-image build-cache boundary", () => { expect(existsSync(dockerLog)).toBe(false); }); - it("rejects incomplete reviewed audit evidence before invoking Docker (#11088)", () => { - const cacheRoot = path.join(testRoot, "imported-cache"); - const auditRoot = path.join(testRoot, "audit-evidence"); - completeImportedCache(cacheRoot); - mkdirSync(auditRoot); - writeFileSync(path.join(auditRoot, "mcporter-runtime.receipt.json"), "", "utf8"); - stubBuildInvocation(); - - const result = runBuild(REPO_ROOT, [ - "--cache-from", - cacheRoot, - "--audit-evidence-from", - auditRoot, - ]); - - expect(result.status, result.stderr).toBe(1); - expect(result.stderr).toContain("reviewed audit evidence is incomplete"); - expect(existsSync(dockerLog)).toBe(false); - }); - - it("binds external evidence to the trusted verifier and candidate graph (#11088)", () => { - const cacheRoot = path.join(testRoot, "imported-cache"); - const auditRoot = path.join(testRoot, "audit-evidence"); - completeImportedCache(cacheRoot); - completeAuditEvidence(auditRoot); - stubBuildInvocation(); - receiptVerifyStatus = "42"; - - const result = runBuild(REPO_ROOT, [ - "--cache-from", - cacheRoot, - "--audit-evidence-from", - auditRoot, - ]); - const verification = readFileSync(seedLog, "utf8"); - - expect(result.status, result.stderr).toBe(42); - expect(verification).toContain(`${REPO_ROOT}/scripts/lib/npm-audit-receipt.mts`); - expect(verification).toContain( - `--package-json ${REPO_ROOT}/agents/openclaw/mcporter-runtime/package.json`, - ); - expect(verification).toContain(`--audit-config ${REPO_ROOT}/ci/reviewed-npm-audit.json`); - expect(existsSync(dockerLog)).toBe(false); - }); - it("imports locked seeds, reuses safe agent caches, and disables RUN network access", () => { const cacheRoot = path.join(testRoot, "imported-cache"); const sourceSeed = path.join(REPO_ROOT, "tools/mcp-tool-discovery-runtime/npm-cache-seed"); @@ -605,17 +510,10 @@ describe("protected managed-image build-cache boundary", () => { const originalSeedNames = readdirSync(sourceSeed).sort(); const originalMcpSeedNames = readdirSync(sourceMcpSeed).sort(); const originalMessagingSeedNames = readdirSync(sourceMessagingSeed).sort(); - const auditRoot = path.join(testRoot, "audit-evidence"); completeImportedCache(cacheRoot); - completeAuditEvidence(auditRoot); stubBuildInvocation(); - const result = runBuild(REPO_ROOT, [ - "--cache-from", - cacheRoot, - "--audit-evidence-from", - auditRoot, - ]); + const result = runBuild(REPO_ROOT, ["--cache-from", cacheRoot]); expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocations()).toHaveLength(3); @@ -641,21 +539,6 @@ describe("protected managed-image build-cache boundary", () => { ), }); expect(recordedBuildInvocation("openclaw").split(" ")).toContain("--no-cache"); - expect(recordedBuildInvocation("openclaw")).toContain( - `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(auditRoot)}/mcporter-runtime.receipt.json`, - ); - expect(recordedBuildInvocation("openclaw")).toContain( - `--secret id=nemoclaw-mcporter-audit-raw-report,src=${realpathSync(auditRoot)}/mcporter-runtime.raw.json`, - ); - expect(recordedBuildInvocation("openclaw")).toMatch( - /--secret id=nemoclaw-mcporter-audit-policy-result,src=\S+\/mcporter-runtime[.]policy[.]json/, - ); - expect(recordedBuildInvocation("openclaw")).toContain( - `--build-arg NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=${DIGEST}`, - ); - expect(recordedBuildInvocation("openclaw")).toContain( - `--build-arg NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256=${DIGEST}`, - ); expect(recordedBuildInvocation("hermes").split(" ")).not.toContain("--no-cache"); expect(recordedBuildInvocation("langchain-deepagents-code").split(" ")).not.toContain( "--no-cache", diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index 81eccf78d80..c0f7e7afc6b 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -47,15 +47,12 @@ const reviewedAuditDriver = fs.readFileSync( path.join(repoRoot, "scripts", "audit-reviewed-npm-graph.mts"), "utf8", ); -const mcporterAuditHelper = fs.readFileSync( - path.join(repoRoot, "scripts", "lib", "verify-mcporter-audit.sh"), - "utf8", -); + function extractIntegrityGate(contents: string): string { const startMarker = 'MCPORTER_EXPECTED_INTEGRITY=""'; const start = contents.indexOf(startMarker); const helperMarker = - "node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts --verify-only"; + "node /scripts/lib/reviewed-npm-archive.mts --verify-only"; const helperStart = contents.indexOf(helperMarker, start); const helperEndMarker = '--label "mcporter ${MCPORTER_VERSION}"'; const helperEnd = contents.indexOf(helperEndMarker, helperStart) + helperEndMarker.length; @@ -71,6 +68,19 @@ function extractIntegrityGate(contents: string): string { .trim(); } +function extractAuditReceiptInvocation(contents: string): string { + const startMarker = "node /scripts/lib/npm-audit-receipt.mts"; + const endMarker = "--legacy-npmjs true"; + const start = contents.indexOf(startMarker); + const end = contents.indexOf(endMarker, start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return contents + .slice(start, end + endMarker.length) + .replace(/\\\s*\n/g, " ") + .replace(/\s+/g, " "); +} + function runIntegrityGate(contents: string, version: string) { const script = [ "set -euo pipefail", @@ -79,14 +89,14 @@ function runIntegrityGate(contents: string, version: string) { `MCPORTER_0_7_3_TARBALL=${JSON.stringify(expectedTarball)}`, `npm() { printf '%s\\n' ${JSON.stringify(expectedIntegrity)}; }`, "node() {", - ' [ "$#" -eq 11 ] && [ "${1:-}" = "--experimental-strip-types" ] || return 81', - ' [ "${2:-}" = "/scripts/lib/reviewed-npm-archive.mts" ] && [ "${3:-}" = "--verify-only" ] || return 82', - ' [ "${4:-}" = "--package-spec" ] && [ "${5:-}" = "mcporter@${MCPORTER_VERSION}" ] || return 83', - ' [ "${6:-}" = "--integrity" ] && [ "${7:-}" = ' + + ' [ "$#" -eq 10 ] || return 81', + ' [ "${1:-}" = "/scripts/lib/reviewed-npm-archive.mts" ] && [ "${2:-}" = "--verify-only" ] || return 82', + ' [ "${3:-}" = "--package-spec" ] && [ "${4:-}" = "mcporter@${MCPORTER_VERSION}" ] || return 83', + ' [ "${5:-}" = "--integrity" ] && [ "${6:-}" = ' + `${JSON.stringify(expectedIntegrity)} ] || return 84`, - ' [ "${8:-}" = "--tarball-url" ] && [ "${9:-}" = ' + + ' [ "${7:-}" = "--tarball-url" ] && [ "${8:-}" = ' + `${JSON.stringify(expectedTarball)} ] || return 85`, - ' [ "${10:-}" = "--label" ] && [ "${11:-}" = "mcporter ${MCPORTER_VERSION}" ] || return 86', + ' [ "${9:-}" = "--label" ] && [ "${10:-}" = "mcporter ${MCPORTER_VERSION}" ] || return 86', "}", extractIntegrityGate(contents), "printf 'gate-passed\\n'", @@ -180,8 +190,8 @@ describe("mcporter image supply-chain controls", () => { }); it.each(dockerfiles)("audits the committed dependency graph in $name", ({ contents }) => { - const auditContents = `${contents}\n${mcporterAuditHelper}`; - const flattenedContents = auditContents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + const auditReceiptInvocation = extractAuditReceiptInvocation(contents); expect(contents).toContain( "COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/", ); @@ -194,24 +204,29 @@ describe("mcporter image supply-chain controls", () => { ), ).toBe(true); expect(flattenedContents).toContain( - "node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", + "node /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", ); expect(contents).toContain("ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256="); - expect(contents).toContain("ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256="); expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false", ); expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false", ); - expect(contents).toContain( - "--mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false", + expect(flattenedContents).toContain( + "node /scripts/lib/npm-audit-receipt.mts --receipt", + ); + expect(flattenedContents).toContain( + "--package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json --raw-report", + ); + expect(auditReceiptInvocation).toContain( + "--exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json --registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true", ); expect(expectedReviewedNpmVersion).toMatch(/^[0-9]+\.[0-9]+\.[0-9]+$/); - expect(auditContents).not.toContain("/scripts/lib/npm-audit-receipt.mts"); - expect(auditContents).toContain("sha256sum --check --status"); - expect(auditContents).toContain("policy_result_sha256"); - expect(auditContents).not.toContain("--raw-copy"); + expect(auditReceiptInvocation).not.toContain("--npm-version"); + expect(contents).not.toContain("--raw-copy"); + expect(auditReceiptInvocation).not.toMatch(/\bnpm\s+--version\b/); + expect(auditReceiptInvocation).not.toMatch(/\$\(|`/); expect(contents).not.toContain(`${runtimePrefix} audit --omit=dev --audit-level=low`); expect(contents).not.toContain(`${runtimePrefix} audit signatures`); expect(flattenedContents).toContain( @@ -224,11 +239,8 @@ describe("mcporter image supply-chain controls", () => { const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile.base"), "utf8"); const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); - expect(contents).toContain( - "COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh", - ); expect(flattenedContents).toContain( - "NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json bash /scripts/lib/verify-mcporter-audit.sh", + '--result /tmp/mcporter-npm-audit-policy.json && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json;', ); }); From 65893ca17594ea21739a37fc4e55a67d5a85db77 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter Date: Wed, 9 Sep 2026 12:31:58 -0700 Subject: [PATCH 42/56] merge: restore reviewed protected audit resolution Restore the independently reviewed conflict resolution after GitHub created verified current-main ancestry. Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- Dockerfile | 26 +- Dockerfile.base | 29 +- .../checks/build-protected-managed-images.sh | 2 +- scripts/lib/verify-mcporter-audit.sh | 2 +- .../reviewed-npm-audit-handoff.test.ts | 351 +++++++++++++++--- ...otected-managed-image-build-script.test.ts | 147 +++++++- test/security/mcporter-supply-chain.test.ts | 46 +-- 7 files changed, 470 insertions(+), 133 deletions(-) diff --git a/Dockerfile b/Dockerfile index 46bfe54c396..5ef95afe229 100644 --- a/Dockerfile +++ b/Dockerfile @@ -544,8 +544,8 @@ COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/ COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts -COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts +COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -634,6 +634,7 @@ ARG MCPORTER_VERSION=0.7.3 ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA== ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256= +ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256= # A cross-stage root copy is accepted by Docker's legacy builder and creates one # final-image layer while preserving metadata on existing parent directories. @@ -817,9 +818,9 @@ RUN command -v codex-acp >/dev/null # OPENCLAW_VERSION is the NemoClaw runtime build target and must meet the blueprint minimum. # Reviewed archives retain registry and packed-byte SRI, basename, local-only install, and cleanup gates. # hadolint ignore=DL3059,DL4006,DL3016,SC2015 -RUN --network=default \ - --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ +RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ + --mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false \ set -eu; \ if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ @@ -983,24 +984,7 @@ RUN --network=default \ ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter; \ test "$(mcporter --version)" = "$MCPORTER_VERSION"; \ fi; \ - MCPORTER_RECEIPT=/run/secrets/nemoclaw-mcporter-audit-receipt; \ - MCPORTER_RAW_REPORT=/run/secrets/nemoclaw-mcporter-audit-raw-report; \ - if [ -f "$MCPORTER_RECEIPT" ] || [ -f "$MCPORTER_RAW_REPORT" ] || [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]; then \ - [ -f "$MCPORTER_RECEIPT" ] && [ -f "$MCPORTER_RAW_REPORT" ] && printf %s "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}' \ - || { echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2; exit 1; }; \ - printf '%s %s\n' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" "$MCPORTER_RECEIPT" | sha256sum -c -; \ -node /scripts/lib/npm-audit-receipt.mts \ ---receipt "$MCPORTER_RECEIPT" \ ---package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ ---package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ ---raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ ---graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ ---registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true; \ - else \ - node /scripts/lib/reviewed-npm-audit.mts \ - --directory /usr/local/lib/nemoclaw/mcporter-runtime \ - --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high; \ - fi + bash /scripts/lib/verify-mcporter-audit.sh # Patch OpenClaw media fetch for proxy-only sandbox (NVIDIA/NemoClaw#1755). # diff --git a/Dockerfile.base b/Dockerfile.base index 60fe775ddf4..8f17d53e4d0 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -413,6 +413,7 @@ ARG MCPORTER_VERSION=0.7.3 ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA== ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256= +ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256= # Keep paired runtime manifests and remediation helpers in grouped layers so # the published base retains its established image layout. COPY agents/openclaw/openclaw-runtime/package.json \ @@ -427,7 +428,7 @@ COPY scripts/lib/reviewed-npm-archive.mts \ scripts/lib/reviewed-npm-audit.mts \ scripts/lib/openclaw-npm-remediation.mts \ /scripts/lib/ -COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts +COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -477,6 +478,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/blueprint.yaml \ --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ + --mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false \ echo "$OPENCLAW_VERSION" | grep -qxE '[0-9]+(\.[0-9]+)*' \ || { echo "Error: OPENCLAW_VERSION='$OPENCLAW_VERSION' is invalid (expected e.g. 2026.3.11)."; exit 1; }; \ OPENCLAW_MIN_VERSION=$(grep -m 1 'min_openclaw_version' /tmp/blueprint.yaml | awk '{print $2}' | tr -d '"'); \ @@ -579,28 +581,9 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep 'const { StreamableHTTPServerTransport } = await import("file:///usr/local/lib/nemoclaw/mcporter-runtime/node_modules/@modelcontextprotocol/sdk/dist/esm/server/streamableHttp.js"); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await transport.close();' \ && ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter \ && test "$(mcporter --version)" = "$MCPORTER_VERSION" \ - && MCPORTER_RECEIPT=/run/secrets/nemoclaw-mcporter-audit-receipt \ - && MCPORTER_RAW_REPORT=/run/secrets/nemoclaw-mcporter-audit-raw-report \ - && if [ -f "$MCPORTER_RECEIPT" ] || [ -f "$MCPORTER_RAW_REPORT" ] || [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]; then \ - [ -f "$MCPORTER_RECEIPT" ] && [ -f "$MCPORTER_RAW_REPORT" ] && printf %s "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}' \ - || { echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2; exit 1; }; \ - printf '%s %s\n' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" "$MCPORTER_RECEIPT" | sha256sum -c -; \ - node /scripts/lib/npm-audit-receipt.mts \ - --receipt "$MCPORTER_RECEIPT" \ - --package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ - --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ - --raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ - --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ - --registry https://registry.yarnpkg.com --threshold high \ - --legacy-npmjs true \ - --result /tmp/mcporter-npm-audit-policy.json \ - && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json; \ - else \ - node /scripts/lib/reviewed-npm-audit.mts \ - --directory /usr/local/lib/nemoclaw/mcporter-runtime \ - --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high \ - --report /tmp/mcporter-npm-audit.json --result /tmp/mcporter-npm-audit-policy.json; \ - fi \ + && NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json \ + NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json \ + bash /scripts/lib/verify-mcporter-audit.sh \ && MCPORTER_AUDIT_STATUS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').status")" \ && MCPORTER_AUDIT_EXCEPTIONS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').acceptedAdvisories.join(',') || 'none'")" \ && MCPORTER_AUDIT_POLICY_SHA256="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').exceptionPolicySha256")" \ diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index 975d6f72493..6108e586792 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -235,7 +235,7 @@ validate_audit_evidence() { } audit_receipt_sha256="$(sha256sum "$audit_receipt" | awk '{print $1}')" audit_policy_result="$work_dir/mcporter-runtime.policy.json" - node --experimental-strip-types --no-warnings "$trusted_receipt_verifier" \ + node --no-warnings "$trusted_receipt_verifier" \ --receipt "$audit_receipt" \ --package-json "$source_root/agents/openclaw/mcporter-runtime/package.json" \ --package-lock "$source_root/agents/openclaw/mcporter-runtime/package-lock.json" \ diff --git a/scripts/lib/verify-mcporter-audit.sh b/scripts/lib/verify-mcporter-audit.sh index 7b1d071b2ba..ceb436f38ef 100755 --- a/scripts/lib/verify-mcporter-audit.sh +++ b/scripts/lib/verify-mcporter-audit.sh @@ -25,7 +25,7 @@ elif [[ -e "$seed" || -L "$seed" ]]; then echo "ERROR: build-context mcporter audit evidence is not trusted" >&2 exit 1 else - node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ + node /scripts/lib/reviewed-npm-audit.mts \ --directory /usr/local/lib/nemoclaw/mcporter-runtime \ --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high \ "${audit_output_args[@]}" diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 7e37b5d1882..38d2c128c5b 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -25,6 +25,9 @@ type Workflow = { string, { readonly steps?: readonly { + readonly name?: string; + readonly run?: string; + readonly uses?: string; readonly with?: Readonly>; }[]; } @@ -89,43 +92,143 @@ describe("reviewed npm audit handoff", () => { }, ); - it("passes producer output to the Docker receipt verifier and rejects an npm mismatch", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")); - const packageJsonFile = path.join(root, "package.json"); - const packageLockFile = path.join(root, "package-lock.json"); - const rawReportFile = path.join(root, "report.json"); - const exceptionFile = path.join(root, "exceptions.json"); - const auditConfigFile = path.join(root, "reviewed-npm-audit.json"); - const resultFile = path.join(root, "policy.json"); - const packageJson = Buffer.from("temporary manifest\n"); - const packageLock = Buffer.from("temporary lock\n"); - const exceptionPolicy = '{"schemaVersion":1,"exceptions":[]}\n'; + // source-shape-contract: security -- Every production image builder must keep the trusted three-file audit handoff atomic because GitHub and BuildKit consume these declarations directly. + it("pairs every production audit receipt with raw and trusted policy results", () => { + const managedWorkflow = YAML.parse( + fs.readFileSync(path.join(REPO_ROOT, ".github/workflows/managed-images.yaml"), "utf8"), + ) as Workflow; + const managedSteps = Object.values(managedWorkflow.jobs ?? {}).flatMap( + (job) => job.steps ?? [], + ); + const managedHandoffs = managedSteps + .map((step) => JSON.stringify(step)) + .filter((source) => source.includes("nemoclaw-mcporter-audit-receipt")); + const baseWorkflow = YAML.parse( + fs.readFileSync(path.join(REPO_ROOT, ".github/workflows/base-image-platform.yaml"), "utf8"), + ) as Workflow; + const baseHandoff = JSON.stringify( + baseWorkflow.jobs?.build?.steps?.find( + ({ name }) => name === "Build and publish platform digest", + ), + ); + const baseAction = YAML.parse( + fs.readFileSync( + path.join(REPO_ROOT, ".github/actions/build-base-image-platform/action.yaml"), + "utf8", + ), + ) as { + readonly runs?: { readonly steps?: readonly { readonly name?: string }[] }; + }; + const baseActionHandoff = JSON.stringify( + baseAction.runs?.steps?.find( + ({ name }) => name === "Build and push platform digest", + ), + ); + const baseActionValidation = JSON.stringify( + baseAction.runs?.steps?.find( + ({ name }) => name === "Validate production Docker build args", + ), + ); + const buildKitHandoffs = [...managedHandoffs, baseActionHandoff]; + + expect(managedHandoffs.length).toBeGreaterThan(0); + expect( + buildKitHandoffs.filter( + (source) => !source.includes("nemoclaw-mcporter-audit-receipt"), + ), + ).toEqual([]); + expect( + buildKitHandoffs.filter( + (source) => !source.includes("nemoclaw-mcporter-audit-raw-report"), + ), + ).toEqual([]); + expect( + buildKitHandoffs.filter( + (source) => !source.includes("nemoclaw-mcporter-audit-policy-result"), + ), + ).toEqual([]); + expect( + managedHandoffs.filter( + (source) => !source.includes("NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256"), + ), + ).toEqual([]); + expect(baseActionValidation).toContain( + "NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256", + ); + expect(baseHandoff).toContain("mcporter-audit-receipt"); + expect(baseHandoff).toContain("mcporter-audit-raw-report"); + expect(baseHandoff).toContain("mcporter-audit-policy-result"); + + const prPreparation = managedSteps.find( + ({ name, run }) => name === "Prepare same-run mcporter audit evidence" && run?.includes("trusted_root"), + ); + expect(prPreparation?.run).toContain('"$trusted_root/scripts/lib/npm-audit-receipt.mts"'); + expect(prPreparation?.run).toContain('--result "$policy"'); + expect(prPreparation?.run).not.toContain( + '"$GITHUB_WORKSPACE/scripts/lib/npm-audit-receipt.mts"', + ); + }); + + it("keeps protected audit acceptance under trusted policy and rejects forged transport", () => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")), + ); + const trustedRoot = path.join(root, "trusted"); + const targetRoot = path.join(root, "target"); + const runtime = path.join(targetRoot, "agents/openclaw/mcporter-runtime"); + const artifactDirectory = path.join(targetRoot, "artifacts/reviewed-npm-audit"); + const exceptionFile = path.join(trustedRoot, "ci/npm-audit-exceptions.json"); + const auditConfigFile = path.join(trustedRoot, "ci/reviewed-npm-audit.json"); + const auditConfig = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), + ); + const npmVersion = auditConfig.npmVersion as string; const rawReport = '{"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n'; try { - fs.writeFileSync(packageJsonFile, packageJson); - fs.writeFileSync(packageLockFile, packageLock); + fs.mkdirSync(runtime, { recursive: true }); + fs.mkdirSync(path.join(trustedRoot, "ci"), { recursive: true }); + fs.cpSync(path.join(REPO_ROOT, "scripts"), path.join(trustedRoot, "scripts"), { + recursive: true, + }); + fs.cpSync(path.join(REPO_ROOT, "agents/openclaw/mcporter-runtime"), runtime, { + recursive: true, + }); + fs.mkdirSync(path.join(targetRoot, "ci"), { recursive: true }); + fs.mkdirSync(path.join(targetRoot, "scripts", "lib"), { recursive: true }); + fs.writeFileSync(path.join(targetRoot, "ci", "reviewed-npm-audit.json"), "{}\n"); + fs.writeFileSync( + path.join(targetRoot, "scripts", "audit-reviewed-npm-graph.mts"), + "throw new Error('candidate producer executed');\n", + ); + fs.writeFileSync( + path.join(targetRoot, "scripts", "lib", "npm-audit-receipt.mts"), + "throw new Error('candidate verifier executed');\n", + ); + fs.copyFileSync(path.join(REPO_ROOT, "ci/npm-audit-exceptions.json"), exceptionFile); + fs.copyFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), auditConfigFile); + fs.mkdirSync(artifactDirectory, { recursive: true }); + const rawReportFile = path.join(artifactDirectory, "audit.json"); fs.writeFileSync(rawReportFile, rawReport); - fs.writeFileSync(exceptionFile, exceptionPolicy); - fs.writeFileSync(auditConfigFile, JSON.stringify({ npmVersion: "10.9.4" })); fs.writeFileSync( - path.join(root, "report.provenance.json"), + path.join(artifactDirectory, "audit.provenance.json"), JSON.stringify({ run: { startedAt: new Date().toISOString() } }), ); - const receiptFile = emitAuditReceipt({ - artifactDirectory: root, - graphId: "temporary-graph", - npmVersion: "10.9.4", - packageJsonFile, - packageLockFile, - preserveInputs: true, + emitAuditReceipt({ + artifactDirectory, + graphId: "mcporter-runtime", + npmVersion, + packageJsonFile: path.join(runtime, "package.json"), + packageLockFile: path.join(runtime, "package-lock.json"), rawReportFile, registryOrigin: "https://registry.yarnpkg.com", result: { acceptedAdvisories: [], blockingThreshold: "high", - exceptionPolicySha256: createHash("sha256").update(exceptionPolicy).digest("hex"), - graph: "temporary-graph", + exceptionPolicySha256: createHash("sha256") + .update(fs.readFileSync(exceptionFile)) + .digest("hex"), + graph: "mcporter-runtime", reported: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, schemaVersion: 1, status: "clean", @@ -134,11 +237,20 @@ describe("reviewed npm audit handoff", () => { threshold: "high", }); - const retainedPackageJson = path.join(root, "temporary-graph.package.json"); - const retainedPackageLock = path.join(root, "temporary-graph.package-lock.json"); - const transportRawReport = path.join(root, "temporary-graph.raw.json"); + const receiptFile = path.join(artifactDirectory, "mcporter-runtime.receipt.json"); + const retainedPackageJson = path.join(runtime, "package.json"); + const retainedPackageLock = path.join(runtime, "package-lock.json"); + const transportRawReport = path.join(artifactDirectory, "mcporter-runtime.raw.json"); + const producerPolicyResult = path.join( + artifactDirectory, + "mcporter-runtime.policy.json", + ); + const trustedPolicyResult = path.join(root, "trusted-policy-result.json"); + const receiptVerifier = path.join(trustedRoot, "scripts", "lib", "npm-audit-receipt.mts"); + const retainedReport = path.join(root, "retained-report.json"); + const retainedResult = path.join(root, "retained-result.json"); const verifierArgs = [ - path.join(REPO_ROOT, "scripts", "lib", "npm-audit-receipt.mts"), + receiptVerifier, "--receipt", receiptFile, "--package-json", @@ -150,32 +262,185 @@ describe("reviewed npm audit handoff", () => { "--exceptions", exceptionFile, "--graph", - "temporary-graph", + "mcporter-runtime", "--audit-config", auditConfigFile, "--registry", "https://registry.yarnpkg.com", "--threshold", "high", + "--legacy-npmjs", + "true", "--result", - resultFile, + trustedPolicyResult, ]; - const accepted = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); + const nodeLog = path.join(root, "node.log"); + const stubBin = path.join(root, "bin"); + const helper = path.join(root, "verify-mcporter-audit.sh"); + fs.mkdirSync(stubBin); + fs.writeFileSync( + path.join(stubBin, "node"), + '#!/usr/bin/env bash\nset -euo pipefail\nprintf \'%s\\n\' "$*" >>"$NEMOCLAW_TEST_NODE_LOG"\n[[ "$*" != *"/scripts/lib/reviewed-npm-audit.mts"* ]] || exit 0\nexec "$NEMOCLAW_TEST_REAL_NODE" "$@"\n', + { mode: 0o755 }, + ); + let helperSource = fs.readFileSync( + path.join(REPO_ROOT, "scripts/lib/verify-mcporter-audit.sh"), + "utf8", + ); + helperSource = helperSource + .replaceAll("/run/secrets/nemoclaw-mcporter-audit-receipt", receiptFile) + .replaceAll("/run/secrets/nemoclaw-mcporter-audit-raw-report", transportRawReport) + .replaceAll( + "/run/secrets/nemoclaw-mcporter-audit-policy-result", + trustedPolicyResult, + ) + .replaceAll( + "/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", + path.join(root, "no-seed"), + ); + fs.writeFileSync(helper, helperSource, { mode: 0o755 }); + const correctReceiptSha256 = createHash("sha256") + .update(fs.readFileSync(receiptFile)) + .digest("hex"); + const policyResultSha256 = () => + createHash("sha256").update(fs.readFileSync(trustedPolicyResult)).digest("hex"); + const runHelper = ( + receiptSha256 = correctReceiptSha256, + trustedPolicyResultSha256 = policyResultSha256(), + helperFile = helper, + ) => + spawnSync("bash", [helperFile], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: receiptSha256, + NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: trustedPolicyResultSha256, + NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, + NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, + NEMOCLAW_TEST_NODE_LOG: nodeLog, + NEMOCLAW_TEST_REAL_NODE: process.execPath, + PATH: `${stubBin}:${process.env.PATH ?? ""}`, + }, + }); + + fs.writeFileSync(transportRawReport, "{}\n"); + expect(JSON.parse(fs.readFileSync(producerPolicyResult, "utf8"))).toMatchObject({ + graph: "mcporter-runtime", + status: "clean", + }); + const rejectedByTrustedPolicy = spawnSync(process.execPath, verifierArgs, { + encoding: "utf8", + }); + expect(rejectedByTrustedPolicy.status).not.toBe(0); + expect(rejectedByTrustedPolicy.stderr).toContain( + "receipt rawResponseSha256 does not match", + ); + expect(fs.existsSync(trustedPolicyResult)).toBe(false); + + fs.writeFileSync(transportRawReport, rawReport); + const acceptedByTrustedPolicy = spawnSync(process.execPath, verifierArgs, { + encoding: "utf8", + }); + expect(acceptedByTrustedPolicy.status, acceptedByTrustedPolicy.stderr).toBe(0); + expect(JSON.parse(fs.readFileSync(trustedPolicyResult, "utf8"))).toMatchObject({ + graph: "mcporter-runtime", + status: "clean", + }); + + const wrongHash = "0".repeat(64); + expect(wrongHash).not.toBe(correctReceiptSha256); + const rejectedTransport = runHelper(wrongHash); + expect(rejectedTransport.status).not.toBe(0); + expect(rejectedTransport.stderr).toContain("receipt hash does not match"); + expect(fs.existsSync(retainedReport)).toBe(false); + expect(fs.existsSync(retainedResult)).toBe(false); + expect(fs.existsSync(nodeLog)).toBe(false); + + const verifiedPolicyResultSha256 = policyResultSha256(); + const forgedPolicyResult = path.join(root, "forged-policy-result.json"); + const forgedPolicyHelper = path.join(root, "verify-forged-mcporter-audit.sh"); + fs.writeFileSync(forgedPolicyResult, '{"graph":"mcporter-runtime","status":"failed"}\n'); + fs.writeFileSync( + forgedPolicyHelper, + helperSource.replaceAll(trustedPolicyResult, forgedPolicyResult), + { mode: 0o755 }, + ); + const rejectedPolicyResult = runHelper( + correctReceiptSha256, + verifiedPolicyResultSha256, + forgedPolicyHelper, + ); + expect(rejectedPolicyResult.status).not.toBe(0); + expect(rejectedPolicyResult.stderr).toContain( + "policy result hash does not match", + ); + expect(fs.existsSync(retainedReport)).toBe(false); + expect(fs.existsSync(retainedResult)).toBe(false); + expect(fs.existsSync(nodeLog)).toBe(false); + + const accepted = runHelper(); expect(accepted.status, accepted.stderr).toBe(0); - expect(fs.readFileSync(retainedPackageJson)).toEqual(packageJson); - expect(fs.readFileSync(retainedPackageLock)).toEqual(packageLock); expect(fs.readFileSync(transportRawReport, "utf8")).toBe(rawReport); - expect(JSON.parse(fs.readFileSync(resultFile, "utf8"))).toMatchObject({ - graph: "temporary-graph", - status: "clean", + expect(fs.readFileSync(retainedReport, "utf8")).toBe(rawReport); + expect(fs.readFileSync(retainedResult, "utf8")).toBe( + fs.readFileSync(trustedPolicyResult, "utf8"), + ); + expect(fs.existsSync(nodeLog)).toBe(false); + + const directHelper = path.join(root, "verify-mcporter-direct-audit.sh"); + fs.writeFileSync( + directHelper, + helperSource + .replaceAll(receiptFile, path.join(root, "missing-direct-receipt")) + .replaceAll(transportRawReport, path.join(root, "missing-direct-report")) + .replaceAll(trustedPolicyResult, path.join(root, "missing-direct-policy-result")), + { mode: 0o755 }, + ); + const direct = spawnSync("bash", [directHelper], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: "", + NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: "", + NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, + NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, + NEMOCLAW_TEST_NODE_LOG: nodeLog, + PATH: `${stubBin}:${process.env.PATH ?? ""}`, + }, }); + expect(direct.status, direct.stderr).toBe(0); + expect(fs.readFileSync(nodeLog, "utf8").trim().split("\n").at(-1)).toContain( + `--report ${retainedReport} --result ${retainedResult}`, + ); - fs.rmSync(resultFile); - fs.writeFileSync(auditConfigFile, JSON.stringify({ npmVersion: "11.18.0" })); - const rejected = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); - expect(rejected.status).not.toBe(0); - expect(rejected.stderr).toContain("receipt identity does not match expected graph and npm"); - expect(fs.existsSync(resultFile)).toBe(false); + const seedEvidence = path.join(root, "seed", "reviewed-npm-audit"); + const seedHelper = path.join(root, "verify-mcporter-seed-audit.sh"); + fs.mkdirSync(seedEvidence, { recursive: true }); + fs.copyFileSync(receiptFile, path.join(seedEvidence, "mcporter-runtime.receipt.json")); + fs.writeFileSync(path.join(seedEvidence, "mcporter-runtime.raw.json"), rawReport); + fs.writeFileSync( + path.join(seedEvidence, "mcporter-runtime.receipt.sha256"), + `${createHash("sha256").update(fs.readFileSync(receiptFile)).digest("hex")}\n`, + ); + fs.writeFileSync( + seedHelper, + helperSource + .replaceAll(receiptFile, path.join(root, "missing-secret-receipt")) + .replaceAll(transportRawReport, path.join(root, "missing-secret-report")) + .replaceAll(trustedPolicyResult, path.join(root, "missing-secret-policy-result")) + .replaceAll(path.join(root, "no-seed"), seedEvidence), + { mode: 0o755 }, + ); + const rejectedSeed = spawnSync("bash", [seedHelper], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: "", + NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: "", + }, + }); + expect(rejectedSeed.status).not.toBe(0); + expect(rejectedSeed.stderr).toContain("build-context mcporter audit evidence is not trusted"); } finally { fs.rmSync(root, { recursive: true, force: true }); } diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index 39ef180f88f..ee63210da2c 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -30,6 +30,7 @@ let stubBin = ""; let dockerLog = ""; let dockerBuildCount = ""; let dockerBuildFailureMode = ""; +let receiptVerifyStatus = ""; let seedLog = ""; let registryCurlExit = ""; let registryLog = ""; @@ -101,6 +102,17 @@ esac `#!/usr/bin/env bash set -euo pipefail printf '%s\n' "$*" >>"$NEMOCLAW_TEST_SEED_LOG" +if [[ "$*" == *"/scripts/lib/npm-audit-receipt.mts"* ]]; then + status="$NEMOCLAW_TEST_RECEIPT_VERIFY_STATUS" + result="" + while (($# > 0)); do + if [[ "$1" == "--result" ]]; then result="$2"; shift 2; else shift; fi + done + if [[ "$status" == 0 && -n "$result" ]]; then + printf '{"status":"clean"}\n' >"$result" + fi + exit "$status" +fi mode="$3" shift 3 output="" @@ -158,6 +170,12 @@ function completeImportedCache(cacheRoot: string): void { writeFileSync(path.join(cacheRoot, "messaging-npm-cache-seed", "manifest.json"), "{}\n", "utf8"); } +function completeAuditEvidence(auditDirectory: string): void { + mkdirSync(auditDirectory, { recursive: true }); + writeFileSync(path.join(auditDirectory, "mcporter-runtime.receipt.json"), '{"result":"pass"}\n'); + writeFileSync(path.join(auditDirectory, "mcporter-runtime.raw.json"), '{"metadata":{}}\n'); +} + function completeSourceBoundary(sourceRoot: string): void { mkdirSync(path.join(sourceRoot, "nemoclaw"), { recursive: true }); mkdirSync(path.join(sourceRoot, "scripts", "checks"), { recursive: true }); @@ -205,7 +223,9 @@ function completeSourceBoundary(sourceRoot: string): void { function recordedBuildInvocations(): string[] { return readFileSync(dockerLog, "utf8") .split("\n") - .filter((line) => line.startsWith("buildx build ")); + .filter( + (line) => line.startsWith("buildx build ") && line.includes("io.nvidia.nemoclaw.agent="), + ); } function recordedBuildInvocation(agent: string): string { @@ -216,11 +236,15 @@ function recordedBuildInvocation(agent: string): string { return invocation!; } -function runBuild( - sourceRoot: string, - extraArgs: readonly string[] = [], - platform = "linux/amd64", -) { +function expectSingleTargetArch(agent: string, architecture: string): void { + expect( + recordedBuildInvocation(agent) + .split(" ") + .filter((argument) => argument === `TARGETARCH=${architecture}`), + ).toHaveLength(1); +} + +function runBuild(sourceRoot: string, extraArgs: readonly string[] = [], platform = "linux/amd64") { const output = path.join(testRoot, "contracts.json"); return spawnSync( "bash", @@ -256,6 +280,7 @@ function runBuild( NEMOCLAW_TEST_REGISTRY_LOG: registryLog, NEMOCLAW_TEST_REGISTRY_STATUS: registryStatus, NEMOCLAW_TEST_REAL_PATH: process.env.PATH ?? "", + NEMOCLAW_TEST_RECEIPT_VERIFY_STATUS: receiptVerifyStatus, NEMOCLAW_TEST_SEED_LOG: seedLog, NEMOCLAW_TEST_TEE_FAILURE_MODE: teeFailureMode, PATH: `${stubBin}:${process.env.PATH ?? ""}`, @@ -271,6 +296,7 @@ beforeEach(() => { dockerLog = path.join(testRoot, "docker.log"); dockerBuildCount = path.join(testRoot, "docker-build-count"); dockerBuildFailureMode = ""; + receiptVerifyStatus = "0"; seedLog = path.join(testRoot, "seed.log"); registryCurlExit = "0"; registryLog = path.join(testRoot, "registry.log"); @@ -330,13 +356,13 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocation("openclaw")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); + expectSingleTargetArch("openclaw", "arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--platform linux/arm64"); + expectSingleTargetArch("hermes", "arm64"); expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( - "--build-arg TARGETARCH=arm64", + "--platform linux/arm64", ); + expectSingleTargetArch("langchain-deepagents-code", "arm64"); }); it("builds every agent without optional cache arguments", () => { @@ -368,11 +394,13 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocations()).toHaveLength(3); expect(recordedBuildInvocation("openclaw")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); + expectSingleTargetArch("openclaw", "arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--build-arg TARGETARCH=arm64"); + expectSingleTargetArch("hermes", "arm64"); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( + "--platform linux/arm64", + ); + expectSingleTargetArch("langchain-deepagents-code", "arm64"); }); it("passes each agent one empty absolute cache export root", () => { @@ -384,6 +412,7 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(existsSync(cacheRoot)).toBe(true); expect(recordedBuildInvocations()).toHaveLength(3); + expect(existsSync(path.join(cacheRoot, "reviewed-npm-audit"))).toBe(false); expect({ openclaw: recordedBuildInvocation("openclaw"), @@ -417,6 +446,27 @@ describe("protected managed-image build-cache boundary", () => { expect(existsSync(path.join(cacheRoot, "messaging-npm-cache-seed", "manifest.json"))).toBe( true, ); + expect(recordedBuildInvocation("openclaw")).not.toContain("nemoclaw-mcporter-audit"); + expect(recordedBuildInvocation("hermes")).not.toContain("nemoclaw-mcporter-audit"); + expect(recordedBuildInvocation("langchain-deepagents-code")).not.toContain( + "nemoclaw-mcporter-audit", + ); + }); + + it("cleans an incomplete cache export after a protected build fails", () => { + const cacheRoot = path.join(testRoot, "export-cache"); + stubBuildInvocation(); + dockerBuildFailureMode = "near-match"; + + const failed = runBuild(REPO_ROOT, ["--cache-to", cacheRoot]); + + expect(failed.status, failed.stderr).toBe(42); + expect(readdirSync(cacheRoot)).toEqual([]); + + dockerBuildFailureMode = ""; + const retried = runBuild(REPO_ROOT, ["--cache-to", cacheRoot]); + + expect(retried.status, retried.stderr).toBe(0); }); it.each([ @@ -496,6 +546,51 @@ describe("protected managed-image build-cache boundary", () => { expect(existsSync(dockerLog)).toBe(false); }); + it("rejects incomplete reviewed audit evidence before invoking Docker (#11088)", () => { + const cacheRoot = path.join(testRoot, "imported-cache"); + const auditRoot = path.join(testRoot, "audit-evidence"); + completeImportedCache(cacheRoot); + mkdirSync(auditRoot); + writeFileSync(path.join(auditRoot, "mcporter-runtime.receipt.json"), "", "utf8"); + stubBuildInvocation(); + + const result = runBuild(REPO_ROOT, [ + "--cache-from", + cacheRoot, + "--audit-evidence-from", + auditRoot, + ]); + + expect(result.status, result.stderr).toBe(1); + expect(result.stderr).toContain("reviewed audit evidence is incomplete"); + expect(existsSync(dockerLog)).toBe(false); + }); + + it("binds external evidence to the trusted verifier and candidate graph (#11088)", () => { + const cacheRoot = path.join(testRoot, "imported-cache"); + const auditRoot = path.join(testRoot, "audit-evidence"); + completeImportedCache(cacheRoot); + completeAuditEvidence(auditRoot); + stubBuildInvocation(); + receiptVerifyStatus = "42"; + + const result = runBuild(REPO_ROOT, [ + "--cache-from", + cacheRoot, + "--audit-evidence-from", + auditRoot, + ]); + const verification = readFileSync(seedLog, "utf8"); + + expect(result.status, result.stderr).toBe(42); + expect(verification).toContain(`${REPO_ROOT}/scripts/lib/npm-audit-receipt.mts`); + expect(verification).toContain( + `--package-json ${REPO_ROOT}/agents/openclaw/mcporter-runtime/package.json`, + ); + expect(verification).toContain(`--audit-config ${REPO_ROOT}/ci/reviewed-npm-audit.json`); + expect(existsSync(dockerLog)).toBe(false); + }); + it("imports locked seeds, reuses safe agent caches, and disables RUN network access", () => { const cacheRoot = path.join(testRoot, "imported-cache"); const sourceSeed = path.join(REPO_ROOT, "tools/mcp-tool-discovery-runtime/npm-cache-seed"); @@ -510,10 +605,17 @@ describe("protected managed-image build-cache boundary", () => { const originalSeedNames = readdirSync(sourceSeed).sort(); const originalMcpSeedNames = readdirSync(sourceMcpSeed).sort(); const originalMessagingSeedNames = readdirSync(sourceMessagingSeed).sort(); + const auditRoot = path.join(testRoot, "audit-evidence"); completeImportedCache(cacheRoot); + completeAuditEvidence(auditRoot); stubBuildInvocation(); - const result = runBuild(REPO_ROOT, ["--cache-from", cacheRoot]); + const result = runBuild(REPO_ROOT, [ + "--cache-from", + cacheRoot, + "--audit-evidence-from", + auditRoot, + ]); expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocations()).toHaveLength(3); @@ -539,6 +641,21 @@ describe("protected managed-image build-cache boundary", () => { ), }); expect(recordedBuildInvocation("openclaw").split(" ")).toContain("--no-cache"); + expect(recordedBuildInvocation("openclaw")).toContain( + `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(auditRoot)}/mcporter-runtime.receipt.json`, + ); + expect(recordedBuildInvocation("openclaw")).toContain( + `--secret id=nemoclaw-mcporter-audit-raw-report,src=${realpathSync(auditRoot)}/mcporter-runtime.raw.json`, + ); + expect(recordedBuildInvocation("openclaw")).toMatch( + /--secret id=nemoclaw-mcporter-audit-policy-result,src=\S+\/mcporter-runtime[.]policy[.]json/, + ); + expect(recordedBuildInvocation("openclaw")).toContain( + `--build-arg NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=${DIGEST}`, + ); + expect(recordedBuildInvocation("openclaw")).toContain( + `--build-arg NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256=${DIGEST}`, + ); expect(recordedBuildInvocation("hermes").split(" ")).not.toContain("--no-cache"); expect(recordedBuildInvocation("langchain-deepagents-code").split(" ")).not.toContain( "--no-cache", diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index c0f7e7afc6b..1ae920e5179 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -47,7 +47,10 @@ const reviewedAuditDriver = fs.readFileSync( path.join(repoRoot, "scripts", "audit-reviewed-npm-graph.mts"), "utf8", ); - +const mcporterAuditHelper = fs.readFileSync( + path.join(repoRoot, "scripts", "lib", "verify-mcporter-audit.sh"), + "utf8", +); function extractIntegrityGate(contents: string): string { const startMarker = 'MCPORTER_EXPECTED_INTEGRITY=""'; const start = contents.indexOf(startMarker); @@ -68,19 +71,6 @@ function extractIntegrityGate(contents: string): string { .trim(); } -function extractAuditReceiptInvocation(contents: string): string { - const startMarker = "node /scripts/lib/npm-audit-receipt.mts"; - const endMarker = "--legacy-npmjs true"; - const start = contents.indexOf(startMarker); - const end = contents.indexOf(endMarker, start); - expect(start).toBeGreaterThanOrEqual(0); - expect(end).toBeGreaterThan(start); - return contents - .slice(start, end + endMarker.length) - .replace(/\\\s*\n/g, " ") - .replace(/\s+/g, " "); -} - function runIntegrityGate(contents: string, version: string) { const script = [ "set -euo pipefail", @@ -190,8 +180,8 @@ describe("mcporter image supply-chain controls", () => { }); it.each(dockerfiles)("audits the committed dependency graph in $name", ({ contents }) => { - const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); - const auditReceiptInvocation = extractAuditReceiptInvocation(contents); + const auditContents = `${contents}\n${mcporterAuditHelper}`; + const flattenedContents = auditContents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); expect(contents).toContain( "COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/", ); @@ -207,26 +197,21 @@ describe("mcporter image supply-chain controls", () => { "node /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", ); expect(contents).toContain("ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256="); + expect(contents).toContain("ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256="); expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false", ); expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false", ); - expect(flattenedContents).toContain( - "node /scripts/lib/npm-audit-receipt.mts --receipt", - ); - expect(flattenedContents).toContain( - "--package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json --raw-report", - ); - expect(auditReceiptInvocation).toContain( - "--exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json --registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true", + expect(contents).toContain( + "--mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false", ); expect(expectedReviewedNpmVersion).toMatch(/^[0-9]+\.[0-9]+\.[0-9]+$/); - expect(auditReceiptInvocation).not.toContain("--npm-version"); - expect(contents).not.toContain("--raw-copy"); - expect(auditReceiptInvocation).not.toMatch(/\bnpm\s+--version\b/); - expect(auditReceiptInvocation).not.toMatch(/\$\(|`/); + expect(auditContents).not.toContain("/scripts/lib/npm-audit-receipt.mts"); + expect(auditContents).toContain("sha256sum --check --status"); + expect(auditContents).toContain("policy_result_sha256"); + expect(auditContents).not.toContain("--raw-copy"); expect(contents).not.toContain(`${runtimePrefix} audit --omit=dev --audit-level=low`); expect(contents).not.toContain(`${runtimePrefix} audit signatures`); expect(flattenedContents).toContain( @@ -239,8 +224,11 @@ describe("mcporter image supply-chain controls", () => { const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile.base"), "utf8"); const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + expect(contents).toContain( + "COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh", + ); expect(flattenedContents).toContain( - '--result /tmp/mcporter-npm-audit-policy.json && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json;', + "NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json bash /scripts/lib/verify-mcporter-audit.sh", ); }); From 320d57d1014bbad390279e41799f220127af8e11 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:30:03 -0700 Subject: [PATCH 43/56] merge: prepare trusted conflict resolution Align the five conflicted paths with current main. This lets GitHub create verified merge ancestry before restoring the reviewed resolution. Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- Dockerfile | 92 +++-- Dockerfile.base | 49 ++- .../reviewed-npm-audit-handoff.test.ts | 353 +++--------------- ...otected-managed-image-build-script.test.ts | 153 +------- test/security/mcporter-supply-chain.test.ts | 62 +-- 5 files changed, 185 insertions(+), 524 deletions(-) diff --git a/Dockerfile b/Dockerfile index d9b6ebed92a..46bfe54c396 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ RUN --network=default /opt/nemoclaw-build-tools/npm-ci-locked.sh \ COPY nemoclaw/src/ /opt/nemoclaw/src/ COPY scripts/checks/verify-openshell-policy-boundary-dependencies.mts /opt/nemoclaw-build-checks/ RUN npm run build \ - && node --experimental-strip-types \ + && node \ /opt/nemoclaw-build-checks/verify-openshell-policy-boundary-dependencies.mts \ /opt/nemoclaw/dist/shared/openshell-policy-boundary.cjs @@ -168,7 +168,7 @@ COPY scripts/checks/materialize-locked-npm-cache-seed.mts /opt/checks/ COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/seed-reviewed-npm-cache.mts /opt/nemoclaw-build-tools/ COPY --from=wechat-npm-archives / /opt/wechat-npm-archives/ RUN --network=none install -d -o root -g root -m 0755 /out/wechat-npm-cache \ - && node --experimental-strip-types /opt/nemoclaw-build-tools/seed-reviewed-npm-cache.mts \ + && node /opt/nemoclaw-build-tools/seed-reviewed-npm-cache.mts \ --lockfile /opt/wechat-runtime/package-lock.json \ --cache /out/wechat-npm-cache \ --registry-origin https://registry.npmjs.org/ \ @@ -180,7 +180,7 @@ RUN --network=none install -d -o root -g root -m 0755 /out/wechat-npm-cache \ --userconfig /dev/null --registry https://registry.npmjs.org/ \ --cache /out/wechat-npm-cache \ && NPM_CONFIG_OFFLINE=true \ - node --experimental-strip-types /opt/nemoclaw-build-tools/reviewed-npm-archive.mts \ + node /opt/nemoclaw-build-tools/reviewed-npm-archive.mts \ --lockfile /opt/wechat-runtime/package-lock.json \ --cache /out/wechat-npm-cache \ --registry-origin https://registry.npmjs.org/ \ @@ -506,7 +506,7 @@ RUN --network=none set -eu; \ *) echo "ERROR: unsupported managed messaging npm target: $TARGETARCH" >&2; exit 1 ;; \ esac; \ install -d -o root -g root -m 0755 /out/npm-cache; \ - node --experimental-strip-types /opt/nemoclaw-build-tools/lib/seed-reviewed-npm-cache.mts \ + node /opt/nemoclaw-build-tools/lib/seed-reviewed-npm-cache.mts \ --lockfile /opt/managed-image-messaging-runtime/package-lock.json \ --cache /out/npm-cache \ --registry-origin https://registry.npmjs.org/ \ @@ -516,7 +516,7 @@ RUN --network=none set -eu; \ --ignore-scripts --omit=dev --legacy-peer-deps \ --userconfig /dev/null --registry https://registry.npmjs.org/ \ --cache /out/npm-cache; \ - node --experimental-strip-types /opt/nemoclaw-build-tools/lib/seed-reviewed-npm-cache.mts \ + node /opt/nemoclaw-build-tools/lib/seed-reviewed-npm-cache.mts \ --packuments-only \ --lockfile /opt/managed-image-messaging-runtime/package-lock.json \ --cache /out/npm-cache \ @@ -544,8 +544,8 @@ COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/ COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts +COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts -COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -634,7 +634,6 @@ ARG MCPORTER_VERSION=0.7.3 ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA== ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256= -ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256= # A cross-stage root copy is accepted by Docker's legacy builder and creates one # final-image layer while preserving metadata on existing parent directories. @@ -687,7 +686,7 @@ RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ fi; \ - node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ + node /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm # Reassert the npm-private brace-expansion fix for the final filesystem. @@ -696,7 +695,7 @@ RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ fi; \ - node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ + node /scripts/patch-bundled-npm-brace-expansion.mts \ --npm-root /usr/local/lib/node_modules/npm # Reassert the npm-private ip-address fix for the final filesystem. When @@ -706,7 +705,7 @@ RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ fi; \ - node --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts \ + node /scripts/lib/patch-bundled-npm-ip-address.mts \ --npm-root /usr/local/lib/node_modules/npm # Harden: remove unnecessary build tools and network probes from base image (#830) @@ -818,9 +817,9 @@ RUN command -v codex-acp >/dev/null # OPENCLAW_VERSION is the NemoClaw runtime build target and must meet the blueprint minimum. # Reviewed archives retain registry and packed-byte SRI, basename, local-only install, and cleanup gates. # hadolint ignore=DL3059,DL4006,DL3016,SC2015 -RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ +RUN --network=default \ + --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ - --mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false \ set -eu; \ if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ @@ -867,7 +866,7 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ [ -n "$MCPORTER_LOCK_SHA256" ] \ || { echo "ERROR: Could not hash the committed mcporter lockfile" >&2; exit 1; }; \ MCPORTER_AUDIT_POLICY_SHA256="$(sha256sum /scripts/npm-audit-exceptions.json | awk '{print $1}')"; \ - MCPORTER_EXPECTED_AUDIT_EXCEPTIONS="$(node --experimental-strip-types --input-type=module -e \ + MCPORTER_EXPECTED_AUDIT_EXCEPTIONS="$(node --input-type=module -e \ 'import fs from "node:fs"; import { parseAuditExceptionRegistry } from "/scripts/lib/reviewed-npm-audit.mts"; const policy=parseAuditExceptionRegistry(fs.readFileSync("/scripts/npm-audit-exceptions.json", "utf-8")); const ids=policy.exceptions.filter((entry)=>entry.graph==="mcporter-runtime").map((entry)=>entry.advisory).sort(); process.stdout.write(ids.join(",") || "none");')"; \ MCPORTER_EXPECTED_AUDIT_STATUS=clean; \ if [ "$MCPORTER_EXPECTED_AUDIT_EXCEPTIONS" != "none" ]; then MCPORTER_EXPECTED_AUDIT_STATUS=accepted-exceptions; fi; \ @@ -923,7 +922,7 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ # files from surviving a same-version reinstall. rm -rf /usr/local/lib/node_modules/openclaw /usr/local/bin/openclaw; \ if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then \ - node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts --verify-lock \ + node /scripts/lib/reviewed-npm-archive.mts --verify-lock \ --lock-sha256 "$OPENCLAW_LOCK_SHA256" \ --lockfile /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json \ --registry-origin https://registry.npmjs.org/ \ @@ -932,7 +931,7 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ npm --prefix /usr/local/lib/nemoclaw/openclaw-runtime ci \ --ignore-scripts --omit=dev --no-audit --no-fund --no-progress \ --userconfig /dev/null --registry https://registry.npmjs.org/; \ - node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ + node /scripts/lib/reviewed-npm-archive.mts \ --verify-installed-lock --lock-sha256 "$OPENCLAW_LOCK_SHA256" \ --lockfile /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json \ --install-root /usr/local/lib/nemoclaw/openclaw-runtime \ @@ -942,13 +941,13 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ ln -s /usr/local/lib/nemoclaw/openclaw-runtime/node_modules/openclaw /usr/local/lib/node_modules/openclaw; \ ln -s /usr/local/lib/nemoclaw/openclaw-runtime/node_modules/.bin/openclaw /usr/local/bin/openclaw; \ else \ - OPENCLAW_SOURCE_PACK_PATH="$(node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ + OPENCLAW_SOURCE_PACK_PATH="$(node /scripts/lib/reviewed-npm-archive.mts \ --package-spec "openclaw@${OPENCLAW_VERSION}" --integrity "$EXPECTED_INTEGRITY" \ --tarball-url "$EXPECTED_TARBALL" --label "OpenClaw ${OPENCLAW_VERSION}")"; \ OPENCLAW_PACK_PATH="$OPENCLAW_SOURCE_PACK_PATH"; \ OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_PACK_PATH")"; \ if [ "$OPENCLAW_VERSION" = "2026.3.11" ]; then \ - OPENCLAW_REMEDIATION_JSON="$(node --experimental-strip-types /scripts/lib/openclaw-npm-remediation.mts \ + OPENCLAW_REMEDIATION_JSON="$(node /scripts/lib/openclaw-npm-remediation.mts \ --archive "$OPENCLAW_SOURCE_PACK_PATH" --package-spec "openclaw@${OPENCLAW_VERSION}" \ --working-directory "$OPENCLAW_PACK_DIR")"; \ OPENCLAW_PACK_PATH="$(node -e 'const value = JSON.parse(process.argv[1]); if (!value.remediated || typeof value.archivePath !== "string") process.exit(1); process.stdout.write(value.archivePath)' "$OPENCLAW_REMEDIATION_JSON")"; \ @@ -968,7 +967,7 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ if [ "$USE_REVIEWED_BASE_RUNTIME" = "1" ]; then \ echo "INFO: Reusing reviewed base mcporter $CUR_MCPORTER_VER with matching lock provenance"; \ else \ - node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts --verify-only \ + node /scripts/lib/reviewed-npm-archive.mts --verify-only \ --package-spec "mcporter@${MCPORTER_VERSION}" --integrity "$MCPORTER_EXPECTED_INTEGRITY" \ --tarball-url "$MCPORTER_EXPECTED_TARBALL" --label "mcporter ${MCPORTER_VERSION}"; \ # Reinstall from the committed lock when matching protected base provenance @@ -984,7 +983,24 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter; \ test "$(mcporter --version)" = "$MCPORTER_VERSION"; \ fi; \ - bash /scripts/lib/verify-mcporter-audit.sh + MCPORTER_RECEIPT=/run/secrets/nemoclaw-mcporter-audit-receipt; \ + MCPORTER_RAW_REPORT=/run/secrets/nemoclaw-mcporter-audit-raw-report; \ + if [ -f "$MCPORTER_RECEIPT" ] || [ -f "$MCPORTER_RAW_REPORT" ] || [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]; then \ + [ -f "$MCPORTER_RECEIPT" ] && [ -f "$MCPORTER_RAW_REPORT" ] && printf %s "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}' \ + || { echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2; exit 1; }; \ + printf '%s %s\n' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" "$MCPORTER_RECEIPT" | sha256sum -c -; \ +node /scripts/lib/npm-audit-receipt.mts \ +--receipt "$MCPORTER_RECEIPT" \ +--package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ +--package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ +--raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ +--graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ +--registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true; \ + else \ + node /scripts/lib/reviewed-npm-audit.mts \ + --directory /usr/local/lib/nemoclaw/mcporter-runtime \ + --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high; \ + fi # Patch OpenClaw media fetch for proxy-only sandbox (NVIDIA/NemoClaw#1755). # @@ -1346,7 +1362,7 @@ RUN set -eu; \ # Removal criteria: drop when upstream OpenClaw fixes openclaw/openclaw#70164 # and openclaw/openclaw#50298, or when NemoClaw no longer ships an affected OpenClaw. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-chat-send.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-chat-send.mts \ /usr/local/lib/node_modules/openclaw/dist # Keep OpenClaw 2026.7.1 scope-upgrade approvals inside the gateway's @@ -1361,7 +1377,7 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-chat- # Removal criteria: drop when upstream OpenClaw can approve the same bounded # self-upgrade through the gateway using only operator.pairing. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-device-self-approval.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-device-self-approval.mts \ /usr/local/lib/node_modules/openclaw/dist # Keep backend RPC initiated by the OpenClaw gateway daemon on loopback while @@ -1376,7 +1392,7 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-devic # gateway URL. # hadolint ignore=DL3059 RUN if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then \ - node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-gateway-daemon-dialback.mts \ + node /usr/local/lib/nemoclaw/patch-openclaw-gateway-daemon-dialback.mts \ /usr/local/lib/node_modules/openclaw/dist; \ fi @@ -1392,7 +1408,7 @@ RUN if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then \ # Removal criteria: drop when upstream OpenClaw emits these structured fields # from its assistant error formatter for unreachable inference failures. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-issue-4434-diagnostics.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-issue-4434-diagnostics.mts \ /usr/local/lib/node_modules/openclaw/dist # Patch OpenClaw's MCP stdio launcher so npx-backed MCP servers run with -y. @@ -1402,7 +1418,7 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-issue # Removal criteria: drop when upstream OpenClaw normalizes npx MCP server args # and emits actionable MCP startup timeout diagnostics. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-mcp-npx.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-mcp-npx.mts \ /usr/local/lib/node_modules/openclaw/dist # Recover from a transient remote Streamable HTTP MCP startup failure. OpenClaw @@ -1416,7 +1432,7 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-mcp-n # Removal criterion: drop when upstream OpenClaw provides bounded startup retry, # negative-catalog invalidation, and temporary-transport failure attribution. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-mcp-reliability.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-mcp-reliability.mts \ /usr/local/lib/node_modules/openclaw/dist # Keep OpenClaw's 1,500 ms tools/list catalog timeout by default. A validated @@ -1427,7 +1443,7 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-mcp-r # Removal criterion: drop when upstream OpenClaw exposes an equivalent bounded # tools/list-only runtime setting. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-mcp-tools-list-timeout.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-mcp-tools-list-timeout.mts \ /usr/local/lib/node_modules/openclaw/dist # Emit a redacted managed-transport diagnostic when a remote Streamable HTTP MCP @@ -1443,14 +1459,14 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-mcp-t # Removal criterion: drop when upstream OpenClaw emits phase-classified, # redacted transport diagnostics for remote MCP fetch failures. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-managed-transport-diagnostics.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-managed-transport-diagnostics.mts \ /usr/local/lib/node_modules/openclaw/dist # Run the compact tool catalog shim for OpenClaw selection runtimes that still # need it. OpenClaw 2026.7.1 ships a built-in catalog surface, so the script # skips cleanly after classifying the compiled selection-*.js shape. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts \ /usr/local/lib/node_modules/openclaw/dist # OpenClaw 2026.7.1 moved gateway startup work into shared and per-agent SQLite @@ -1468,7 +1484,7 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-tool- # group-shared state databases and split-user cache migrations without # startup warnings. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-shared-state-permissions.mts \ +RUN node /usr/local/lib/nemoclaw/patch-openclaw-shared-state-permissions.mts \ /usr/local/lib/node_modules/openclaw/dist # Set up blueprint for local resolution. @@ -1667,7 +1683,7 @@ USER sandbox # block until after build-time OpenClaw doctor/plugin commands complete. RUN NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=0 \ NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 \ - node --experimental-strip-types /scripts/generate-openclaw-config.mts + node /scripts/generate-openclaw-config.mts # Validate the patched OpenClaw tool-search contract against real generated # configs for both supported disclosure modes. This runs at image build time so @@ -1685,8 +1701,8 @@ RUN set -eu; \ NEMOCLAW_TOOL_DISCLOSURE="$mode" \ NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=0 \ NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 \ - node --experimental-strip-types /scripts/generate-openclaw-config.mts; \ - node --experimental-strip-types /scripts/validate-openclaw-tool-search.mts \ + node /scripts/generate-openclaw-config.mts; \ + node /scripts/validate-openclaw-tool-search.mts \ /usr/local/lib/node_modules/openclaw/dist \ "$validation_home/.openclaw/openclaw.json" \ "$mode" \ @@ -1724,7 +1740,7 @@ RUN --network=none --mount=from=openclaw-optional-plugin-archives,target=/opt/ne "$plugin_archive" "$expected_integrity"; \ printf '%s\n' "$plugin_archive"; \ else \ - node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ + node /scripts/lib/reviewed-npm-archive.mts \ --package-spec "$plugin_spec" --integrity "$expected_integrity" \ --tarball-url "$expected_tarball" --label "OpenClaw plugin ${plugin_spec}"; \ fi; \ @@ -1737,7 +1753,7 @@ RUN --network=none --mount=from=openclaw-optional-plugin-archives,target=/opt/ne plugin_install_archive="$plugin_archive"; \ case "$plugin_spec" in \ "@openclaw/diagnostics-otel@2026.7.1") \ - remediation_json="$(node --experimental-strip-types /scripts/lib/openclaw-npm-remediation.mts \ + remediation_json="$(node /scripts/lib/openclaw-npm-remediation.mts \ --archive "$plugin_archive" --package-spec "$plugin_spec" \ --working-directory "$plugin_work_root")"; \ plugin_install_archive="$(node -e 'const value = JSON.parse(process.argv[1]); if (!value.remediated || typeof value.archivePath !== "string") process.exit(1); process.stdout.write(value.archivePath)' "$remediation_json")" \ @@ -1792,7 +1808,7 @@ RUN chmod 755 /src/lib/messaging/applier/build/messaging-build-applier.mts \ # forwards explicit runtime env, so nemoclaw-start reads this generic artifact # when the env plan is absent. # hadolint ignore=DL3059 -RUN OPENCLAW_VERSION="${OPENCLAW_VERSION}" node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase runtime-setup +RUN OPENCLAW_VERSION="${OPENCLAW_VERSION}" node /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase runtime-setup USER sandbox # npm still needs a writable _cacache/tmp while OpenClaw packs the verified archive, @@ -1827,7 +1843,7 @@ RUN --mount=from=openclaw-managed-messaging-npm-cache,source=/out/npm-cache,targ fi; \ NEMOCLAW_WECHAT_NPM_INSTALL_CACHE="$install_cache" \ OPENCLAW_VERSION="${OPENCLAW_VERSION}" \ - node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts \ + node /src/lib/messaging/applier/build/messaging-build-applier.mts \ --agent openclaw --phase "$messaging_phase"; \ rm -rf "$install_cache"; \ trap - EXIT; \ @@ -1968,7 +1984,7 @@ RUN NPM_CONFIG_IGNORE_SCRIPTS=true npm_config_ignore_scripts=true \ # Apply messaging render and post-agent-install build-file hooks after agent/plugin installation. # hadolint ignore=DL3059,DL4006 -RUN OPENCLAW_VERSION="${OPENCLAW_VERSION}" node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase post-agent-install +RUN OPENCLAW_VERSION="${OPENCLAW_VERSION}" node /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase post-agent-install # A managed image is a neutral capability carrier, not an all-channels-enabled # deployment. Regenerate after every optional plugin is installed so OpenClaw's @@ -1976,7 +1992,7 @@ RUN OPENCLAW_VERSION="${OPENCLAW_VERSION}" node --experimental-strip-types /src/ # Validate the generated file through the pinned OpenClaw CLI. # hadolint ignore=DL3059,DL4006,SC2016 RUN if [ "$NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION" = "1" ]; then \ - node --experimental-strip-types /scripts/generate-openclaw-config.mts; \ + node /scripts/generate-openclaw-config.mts; \ validation="$(openclaw config validate --json)"; \ node -e 'const result=JSON.parse(process.argv[1]); if (result.valid !== true) process.exit(1)' "$validation"; \ node -e 'const fs=require("node:fs"), path=require("node:path"); const config=JSON.parse(fs.readFileSync("/sandbox/.openclaw/openclaw.json", "utf8")); const root="/usr/local/lib/node_modules/openclaw/dist/extensions"; const bundled=fs.readdirSync(root, {withFileTypes:true}).filter((entry)=>entry.isDirectory()).map((entry)=>entry.name).flatMap((id)=>{ const packagePath=path.join(root, id, "package.json"); if (!fs.existsSync(packagePath)) return []; const packageManifest=JSON.parse(fs.readFileSync(packagePath, "utf8")); if (!packageManifest.openclaw?.channel?.id) return []; const pluginManifest=JSON.parse(fs.readFileSync(path.join(root, id, "openclaw.plugin.json"), "utf8")); return [{channelId:packageManifest.openclaw.channel.id, pluginId:pluginManifest.id}]; }); if (!bundled.some(({channelId})=>channelId === "imessage") || !bundled.some(({channelId})=>channelId === "telegram")) throw new Error(`unexpected bundled OpenClaw channel inventory: ${bundled.map(({channelId})=>channelId).join(",")}`); for (const {channelId, pluginId} of bundled) { if (config.plugins?.entries?.[pluginId]?.enabled !== false || config.channels?.[channelId]?.enabled !== false) throw new Error(`bundled OpenClaw channel is not neutral: ${channelId}`); }'; \ diff --git a/Dockerfile.base b/Dockerfile.base index d4087bd6d46..60fe775ddf4 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -413,7 +413,6 @@ ARG MCPORTER_VERSION=0.7.3 ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA== ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256= -ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256= # Keep paired runtime manifests and remediation helpers in grouped layers so # the published base retains its established image layout. COPY agents/openclaw/openclaw-runtime/package.json \ @@ -428,7 +427,7 @@ COPY scripts/lib/reviewed-npm-archive.mts \ scripts/lib/reviewed-npm-audit.mts \ scripts/lib/openclaw-npm-remediation.mts \ /scripts/lib/ -COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh +COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -436,31 +435,31 @@ COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts # npm 10.9.8 in the pinned Node 22 image bundles an affected node-tar copy. # Replace it before npm processes the reviewed npm archive. -RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ +RUN node /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm # Upgrade the complete private npm tree so its sigstore, brace-expansion, and # picomatch packages meet the reviewed security floors. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts \ +RUN node /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm # npm 11.18.0 restores affected tar 7.5.19. Replace it from the # registry- and SRI-verified 7.5.21 archive before any npm consumers run. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ +RUN node /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm # npm 11.18.0 contains brace-expansion 5.0.7. Replace only that private # package from the reviewed 5.0.9 archive after the complete npm upgrade. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ +RUN node /scripts/patch-bundled-npm-brace-expansion.mts \ --npm-root /usr/local/lib/node_modules/npm # npm 11.18.0 contains ip-address 10.2.0. Replace only that private package # with the reviewed 10.3.1 archive after the complete npm upgrade. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts \ +RUN node /scripts/lib/patch-bundled-npm-ip-address.mts \ --npm-root /usr/local/lib/node_modules/npm # Keep OpenClaw's jiti-generated source cache out of /tmp so provider marker @@ -478,7 +477,6 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/blueprint.yaml \ --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ - --mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false \ echo "$OPENCLAW_VERSION" | grep -qxE '[0-9]+(\.[0-9]+)*' \ || { echo "Error: OPENCLAW_VERSION='$OPENCLAW_VERSION' is invalid (expected e.g. 2026.3.11)."; exit 1; }; \ OPENCLAW_MIN_VERSION=$(grep -m 1 'min_openclaw_version' /tmp/blueprint.yaml | awk '{print $2}' | tr -d '"'); \ @@ -512,7 +510,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep ACTUAL_OPENCLAW_LOCK_SHA256="$(sha256sum /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json | awk '{print $1}')"; \ [ "$ACTUAL_OPENCLAW_LOCK_SHA256" = "$OPENCLAW_LOCK_SHA256" ] \ || { echo "Error: OpenClaw lock SHA-256 mismatch (expected $OPENCLAW_LOCK_SHA256, found $ACTUAL_OPENCLAW_LOCK_SHA256)"; exit 1; }; \ - node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts --verify-lock \ + node /scripts/lib/reviewed-npm-archive.mts --verify-lock \ --lock-sha256 "$OPENCLAW_LOCK_SHA256" \ --lockfile /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json \ --registry-origin https://registry.npmjs.org/ \ @@ -522,7 +520,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep npm --prefix /usr/local/lib/nemoclaw/openclaw-runtime ci \ --ignore-scripts --omit=dev --no-audit --no-fund --no-progress \ --userconfig /dev/null --registry https://registry.npmjs.org/; \ - node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ + node /scripts/lib/reviewed-npm-archive.mts \ --verify-installed-lock --lock-sha256 "$OPENCLAW_LOCK_SHA256" \ --lockfile /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json \ --install-root /usr/local/lib/nemoclaw/openclaw-runtime \ @@ -533,7 +531,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep ln -s /usr/local/lib/nemoclaw/openclaw-runtime/node_modules/.bin/openclaw /usr/local/bin/openclaw; \ OPENCLAW_RECIPE='locked-ci+reviewed-lifecycle-v2'; \ else \ - OPENCLAW_SOURCE_PACK_PATH="$(node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ + OPENCLAW_SOURCE_PACK_PATH="$(node /scripts/lib/reviewed-npm-archive.mts \ --package-spec "openclaw@${OPENCLAW_VERSION}" --integrity "$EXPECTED_INTEGRITY" \ --tarball-url "$EXPECTED_TARBALL" --label "OpenClaw ${OPENCLAW_VERSION}")"; \ if [ -z "$OPENCLAW_SOURCE_PACK_PATH" ] || [ ! -f "$OPENCLAW_SOURCE_PACK_PATH" ] || [ -L "$OPENCLAW_SOURCE_PACK_PATH" ]; then \ @@ -542,7 +540,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep OPENCLAW_PACK_PATH="$OPENCLAW_SOURCE_PACK_PATH"; \ OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_PACK_PATH")"; \ if [ "$OPENCLAW_VERSION" = "2026.3.11" ]; then \ - OPENCLAW_REMEDIATION_JSON="$(node --experimental-strip-types /scripts/lib/openclaw-npm-remediation.mts \ + OPENCLAW_REMEDIATION_JSON="$(node /scripts/lib/openclaw-npm-remediation.mts \ --archive "$OPENCLAW_SOURCE_PACK_PATH" --package-spec "openclaw@${OPENCLAW_VERSION}" \ --working-directory "$OPENCLAW_PACK_DIR")"; \ OPENCLAW_PACK_PATH="$(node -e 'const value = JSON.parse(process.argv[1]); if (!value.remediated || typeof value.archivePath !== "string") process.exit(1); process.stdout.write(value.archivePath)' "$OPENCLAW_REMEDIATION_JSON")"; \ @@ -569,7 +567,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep && if [ -z "$MCPORTER_EXPECTED_INTEGRITY" ]; then \ echo "ERROR: mcporter ${MCPORTER_VERSION} has no committed npm integrity pin" >&2; exit 1; \ fi \ - && node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts --verify-only \ + && node /scripts/lib/reviewed-npm-archive.mts --verify-only \ --package-spec "mcporter@${MCPORTER_VERSION}" --integrity "$MCPORTER_EXPECTED_INTEGRITY" \ --tarball-url "$MCPORTER_EXPECTED_TARBALL" --label "mcporter ${MCPORTER_VERSION}" \ && rm -rf /usr/local/lib/node_modules/mcporter /usr/local/bin/mcporter \ @@ -581,9 +579,28 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep 'const { StreamableHTTPServerTransport } = await import("file:///usr/local/lib/nemoclaw/mcporter-runtime/node_modules/@modelcontextprotocol/sdk/dist/esm/server/streamableHttp.js"); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await transport.close();' \ && ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter \ && test "$(mcporter --version)" = "$MCPORTER_VERSION" \ - && NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json \ - NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json \ - bash /scripts/lib/verify-mcporter-audit.sh \ + && MCPORTER_RECEIPT=/run/secrets/nemoclaw-mcporter-audit-receipt \ + && MCPORTER_RAW_REPORT=/run/secrets/nemoclaw-mcporter-audit-raw-report \ + && if [ -f "$MCPORTER_RECEIPT" ] || [ -f "$MCPORTER_RAW_REPORT" ] || [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]; then \ + [ -f "$MCPORTER_RECEIPT" ] && [ -f "$MCPORTER_RAW_REPORT" ] && printf %s "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}' \ + || { echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2; exit 1; }; \ + printf '%s %s\n' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" "$MCPORTER_RECEIPT" | sha256sum -c -; \ + node /scripts/lib/npm-audit-receipt.mts \ + --receipt "$MCPORTER_RECEIPT" \ + --package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ + --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ + --raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ + --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ + --registry https://registry.yarnpkg.com --threshold high \ + --legacy-npmjs true \ + --result /tmp/mcporter-npm-audit-policy.json \ + && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json; \ + else \ + node /scripts/lib/reviewed-npm-audit.mts \ + --directory /usr/local/lib/nemoclaw/mcporter-runtime \ + --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high \ + --report /tmp/mcporter-npm-audit.json --result /tmp/mcporter-npm-audit-policy.json; \ + fi \ && MCPORTER_AUDIT_STATUS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').status")" \ && MCPORTER_AUDIT_EXCEPTIONS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').acceptedAdvisories.join(',') || 'none'")" \ && MCPORTER_AUDIT_POLICY_SHA256="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').exceptionPolicySha256")" \ diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 5c967242614..7e37b5d1882 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -25,9 +25,6 @@ type Workflow = { string, { readonly steps?: readonly { - readonly name?: string; - readonly run?: string; - readonly uses?: string; readonly with?: Readonly>; }[]; } @@ -77,7 +74,6 @@ describe("reviewed npm audit handoff", () => { const result = spawnSync( process.execPath, [ - "--experimental-strip-types", "--input-type=module", "--eval", "await import(process.argv[1])", @@ -93,143 +89,43 @@ describe("reviewed npm audit handoff", () => { }, ); - // source-shape-contract: security -- Every production image builder must keep the trusted three-file audit handoff atomic because GitHub and BuildKit consume these declarations directly. - it("pairs every production audit receipt with raw and trusted policy results", () => { - const managedWorkflow = YAML.parse( - fs.readFileSync(path.join(REPO_ROOT, ".github/workflows/managed-images.yaml"), "utf8"), - ) as Workflow; - const managedSteps = Object.values(managedWorkflow.jobs ?? {}).flatMap( - (job) => job.steps ?? [], - ); - const managedHandoffs = managedSteps - .map((step) => JSON.stringify(step)) - .filter((source) => source.includes("nemoclaw-mcporter-audit-receipt")); - const baseWorkflow = YAML.parse( - fs.readFileSync(path.join(REPO_ROOT, ".github/workflows/base-image-platform.yaml"), "utf8"), - ) as Workflow; - const baseHandoff = JSON.stringify( - baseWorkflow.jobs?.build?.steps?.find( - ({ name }) => name === "Build and publish platform digest", - ), - ); - const baseAction = YAML.parse( - fs.readFileSync( - path.join(REPO_ROOT, ".github/actions/build-base-image-platform/action.yaml"), - "utf8", - ), - ) as { - readonly runs?: { readonly steps?: readonly { readonly name?: string }[] }; - }; - const baseActionHandoff = JSON.stringify( - baseAction.runs?.steps?.find( - ({ name }) => name === "Build and push platform digest", - ), - ); - const baseActionValidation = JSON.stringify( - baseAction.runs?.steps?.find( - ({ name }) => name === "Validate production Docker build args", - ), - ); - const buildKitHandoffs = [...managedHandoffs, baseActionHandoff]; - - expect(managedHandoffs.length).toBeGreaterThan(0); - expect( - buildKitHandoffs.filter( - (source) => !source.includes("nemoclaw-mcporter-audit-receipt"), - ), - ).toEqual([]); - expect( - buildKitHandoffs.filter( - (source) => !source.includes("nemoclaw-mcporter-audit-raw-report"), - ), - ).toEqual([]); - expect( - buildKitHandoffs.filter( - (source) => !source.includes("nemoclaw-mcporter-audit-policy-result"), - ), - ).toEqual([]); - expect( - managedHandoffs.filter( - (source) => !source.includes("NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256"), - ), - ).toEqual([]); - expect(baseActionValidation).toContain( - "NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256", - ); - expect(baseHandoff).toContain("mcporter-audit-receipt"); - expect(baseHandoff).toContain("mcporter-audit-raw-report"); - expect(baseHandoff).toContain("mcporter-audit-policy-result"); - - const prPreparation = managedSteps.find( - ({ name, run }) => name === "Prepare same-run mcporter audit evidence" && run?.includes("trusted_root"), - ); - expect(prPreparation?.run).toContain('"$trusted_root/scripts/lib/npm-audit-receipt.mts"'); - expect(prPreparation?.run).toContain('--result "$policy"'); - expect(prPreparation?.run).not.toContain( - '"$GITHUB_WORKSPACE/scripts/lib/npm-audit-receipt.mts"', - ); - }); - - it("keeps protected audit acceptance under trusted policy and rejects forged transport", () => { - const root = fs.realpathSync( - fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")), - ); - const trustedRoot = path.join(root, "trusted"); - const targetRoot = path.join(root, "target"); - const runtime = path.join(targetRoot, "agents/openclaw/mcporter-runtime"); - const artifactDirectory = path.join(targetRoot, "artifacts/reviewed-npm-audit"); - const exceptionFile = path.join(trustedRoot, "ci/npm-audit-exceptions.json"); - const auditConfigFile = path.join(trustedRoot, "ci/reviewed-npm-audit.json"); - const auditConfig = JSON.parse( - fs.readFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), - ); - const npmVersion = auditConfig.npmVersion as string; + it("passes producer output to the Docker receipt verifier and rejects an npm mismatch", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")); + const packageJsonFile = path.join(root, "package.json"); + const packageLockFile = path.join(root, "package-lock.json"); + const rawReportFile = path.join(root, "report.json"); + const exceptionFile = path.join(root, "exceptions.json"); + const auditConfigFile = path.join(root, "reviewed-npm-audit.json"); + const resultFile = path.join(root, "policy.json"); + const packageJson = Buffer.from("temporary manifest\n"); + const packageLock = Buffer.from("temporary lock\n"); + const exceptionPolicy = '{"schemaVersion":1,"exceptions":[]}\n'; const rawReport = '{"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n'; try { - fs.mkdirSync(runtime, { recursive: true }); - fs.mkdirSync(path.join(trustedRoot, "ci"), { recursive: true }); - fs.cpSync(path.join(REPO_ROOT, "scripts"), path.join(trustedRoot, "scripts"), { - recursive: true, - }); - fs.cpSync(path.join(REPO_ROOT, "agents/openclaw/mcporter-runtime"), runtime, { - recursive: true, - }); - fs.mkdirSync(path.join(targetRoot, "ci"), { recursive: true }); - fs.mkdirSync(path.join(targetRoot, "scripts", "lib"), { recursive: true }); - fs.writeFileSync(path.join(targetRoot, "ci", "reviewed-npm-audit.json"), "{}\n"); - fs.writeFileSync( - path.join(targetRoot, "scripts", "audit-reviewed-npm-graph.mts"), - "throw new Error('candidate producer executed');\n", - ); - fs.writeFileSync( - path.join(targetRoot, "scripts", "lib", "npm-audit-receipt.mts"), - "throw new Error('candidate verifier executed');\n", - ); - fs.copyFileSync(path.join(REPO_ROOT, "ci/npm-audit-exceptions.json"), exceptionFile); - fs.copyFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), auditConfigFile); - fs.mkdirSync(artifactDirectory, { recursive: true }); - const rawReportFile = path.join(artifactDirectory, "audit.json"); + fs.writeFileSync(packageJsonFile, packageJson); + fs.writeFileSync(packageLockFile, packageLock); fs.writeFileSync(rawReportFile, rawReport); + fs.writeFileSync(exceptionFile, exceptionPolicy); + fs.writeFileSync(auditConfigFile, JSON.stringify({ npmVersion: "10.9.4" })); fs.writeFileSync( - path.join(artifactDirectory, "audit.provenance.json"), + path.join(root, "report.provenance.json"), JSON.stringify({ run: { startedAt: new Date().toISOString() } }), ); - emitAuditReceipt({ - artifactDirectory, - graphId: "mcporter-runtime", - npmVersion, - packageJsonFile: path.join(runtime, "package.json"), - packageLockFile: path.join(runtime, "package-lock.json"), + const receiptFile = emitAuditReceipt({ + artifactDirectory: root, + graphId: "temporary-graph", + npmVersion: "10.9.4", + packageJsonFile, + packageLockFile, + preserveInputs: true, rawReportFile, registryOrigin: "https://registry.yarnpkg.com", result: { acceptedAdvisories: [], blockingThreshold: "high", - exceptionPolicySha256: createHash("sha256") - .update(fs.readFileSync(exceptionFile)) - .digest("hex"), - graph: "mcporter-runtime", + exceptionPolicySha256: createHash("sha256").update(exceptionPolicy).digest("hex"), + graph: "temporary-graph", reported: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, schemaVersion: 1, status: "clean", @@ -238,21 +134,11 @@ describe("reviewed npm audit handoff", () => { threshold: "high", }); - const receiptFile = path.join(artifactDirectory, "mcporter-runtime.receipt.json"); - const retainedPackageJson = path.join(runtime, "package.json"); - const retainedPackageLock = path.join(runtime, "package-lock.json"); - const transportRawReport = path.join(artifactDirectory, "mcporter-runtime.raw.json"); - const producerPolicyResult = path.join( - artifactDirectory, - "mcporter-runtime.policy.json", - ); - const trustedPolicyResult = path.join(root, "trusted-policy-result.json"); - const receiptVerifier = path.join(trustedRoot, "scripts", "lib", "npm-audit-receipt.mts"); - const retainedReport = path.join(root, "retained-report.json"); - const retainedResult = path.join(root, "retained-result.json"); + const retainedPackageJson = path.join(root, "temporary-graph.package.json"); + const retainedPackageLock = path.join(root, "temporary-graph.package-lock.json"); + const transportRawReport = path.join(root, "temporary-graph.raw.json"); const verifierArgs = [ - "--experimental-strip-types", - receiptVerifier, + path.join(REPO_ROOT, "scripts", "lib", "npm-audit-receipt.mts"), "--receipt", receiptFile, "--package-json", @@ -264,185 +150,32 @@ describe("reviewed npm audit handoff", () => { "--exceptions", exceptionFile, "--graph", - "mcporter-runtime", + "temporary-graph", "--audit-config", auditConfigFile, "--registry", "https://registry.yarnpkg.com", "--threshold", "high", - "--legacy-npmjs", - "true", "--result", - trustedPolicyResult, + resultFile, ]; - const nodeLog = path.join(root, "node.log"); - const stubBin = path.join(root, "bin"); - const helper = path.join(root, "verify-mcporter-audit.sh"); - fs.mkdirSync(stubBin); - fs.writeFileSync( - path.join(stubBin, "node"), - '#!/usr/bin/env bash\nset -euo pipefail\nprintf \'%s\\n\' "$*" >>"$NEMOCLAW_TEST_NODE_LOG"\n[[ "$*" != *"/scripts/lib/reviewed-npm-audit.mts"* ]] || exit 0\nexec "$NEMOCLAW_TEST_REAL_NODE" "$@"\n', - { mode: 0o755 }, - ); - let helperSource = fs.readFileSync( - path.join(REPO_ROOT, "scripts/lib/verify-mcporter-audit.sh"), - "utf8", - ); - helperSource = helperSource - .replaceAll("/run/secrets/nemoclaw-mcporter-audit-receipt", receiptFile) - .replaceAll("/run/secrets/nemoclaw-mcporter-audit-raw-report", transportRawReport) - .replaceAll( - "/run/secrets/nemoclaw-mcporter-audit-policy-result", - trustedPolicyResult, - ) - .replaceAll( - "/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", - path.join(root, "no-seed"), - ); - fs.writeFileSync(helper, helperSource, { mode: 0o755 }); - const correctReceiptSha256 = createHash("sha256") - .update(fs.readFileSync(receiptFile)) - .digest("hex"); - const policyResultSha256 = () => - createHash("sha256").update(fs.readFileSync(trustedPolicyResult)).digest("hex"); - const runHelper = ( - receiptSha256 = correctReceiptSha256, - trustedPolicyResultSha256 = policyResultSha256(), - helperFile = helper, - ) => - spawnSync("bash", [helperFile], { - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: receiptSha256, - NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: trustedPolicyResultSha256, - NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, - NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, - NEMOCLAW_TEST_NODE_LOG: nodeLog, - NEMOCLAW_TEST_REAL_NODE: process.execPath, - PATH: `${stubBin}:${process.env.PATH ?? ""}`, - }, - }); - - fs.writeFileSync(transportRawReport, "{}\n"); - expect(JSON.parse(fs.readFileSync(producerPolicyResult, "utf8"))).toMatchObject({ - graph: "mcporter-runtime", - status: "clean", - }); - const rejectedByTrustedPolicy = spawnSync(process.execPath, verifierArgs, { - encoding: "utf8", - }); - expect(rejectedByTrustedPolicy.status).not.toBe(0); - expect(rejectedByTrustedPolicy.stderr).toContain( - "receipt rawResponseSha256 does not match", - ); - expect(fs.existsSync(trustedPolicyResult)).toBe(false); - - fs.writeFileSync(transportRawReport, rawReport); - const acceptedByTrustedPolicy = spawnSync(process.execPath, verifierArgs, { - encoding: "utf8", - }); - expect(acceptedByTrustedPolicy.status, acceptedByTrustedPolicy.stderr).toBe(0); - expect(JSON.parse(fs.readFileSync(trustedPolicyResult, "utf8"))).toMatchObject({ - graph: "mcporter-runtime", - status: "clean", - }); - - const wrongHash = "0".repeat(64); - expect(wrongHash).not.toBe(correctReceiptSha256); - const rejectedTransport = runHelper(wrongHash); - expect(rejectedTransport.status).not.toBe(0); - expect(rejectedTransport.stderr).toContain("receipt hash does not match"); - expect(fs.existsSync(retainedReport)).toBe(false); - expect(fs.existsSync(retainedResult)).toBe(false); - expect(fs.existsSync(nodeLog)).toBe(false); - - const verifiedPolicyResultSha256 = policyResultSha256(); - const forgedPolicyResult = path.join(root, "forged-policy-result.json"); - const forgedPolicyHelper = path.join(root, "verify-forged-mcporter-audit.sh"); - fs.writeFileSync(forgedPolicyResult, '{"graph":"mcporter-runtime","status":"failed"}\n'); - fs.writeFileSync( - forgedPolicyHelper, - helperSource.replaceAll(trustedPolicyResult, forgedPolicyResult), - { mode: 0o755 }, - ); - const rejectedPolicyResult = runHelper( - correctReceiptSha256, - verifiedPolicyResultSha256, - forgedPolicyHelper, - ); - expect(rejectedPolicyResult.status).not.toBe(0); - expect(rejectedPolicyResult.stderr).toContain( - "policy result hash does not match", - ); - expect(fs.existsSync(retainedReport)).toBe(false); - expect(fs.existsSync(retainedResult)).toBe(false); - expect(fs.existsSync(nodeLog)).toBe(false); - - const accepted = runHelper(); + const accepted = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); expect(accepted.status, accepted.stderr).toBe(0); + expect(fs.readFileSync(retainedPackageJson)).toEqual(packageJson); + expect(fs.readFileSync(retainedPackageLock)).toEqual(packageLock); expect(fs.readFileSync(transportRawReport, "utf8")).toBe(rawReport); - expect(fs.readFileSync(retainedReport, "utf8")).toBe(rawReport); - expect(fs.readFileSync(retainedResult, "utf8")).toBe( - fs.readFileSync(trustedPolicyResult, "utf8"), - ); - expect(fs.existsSync(nodeLog)).toBe(false); - - const directHelper = path.join(root, "verify-mcporter-direct-audit.sh"); - fs.writeFileSync( - directHelper, - helperSource - .replaceAll(receiptFile, path.join(root, "missing-direct-receipt")) - .replaceAll(transportRawReport, path.join(root, "missing-direct-report")) - .replaceAll(trustedPolicyResult, path.join(root, "missing-direct-policy-result")), - { mode: 0o755 }, - ); - const direct = spawnSync("bash", [directHelper], { - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: "", - NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: "", - NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, - NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, - NEMOCLAW_TEST_NODE_LOG: nodeLog, - PATH: `${stubBin}:${process.env.PATH ?? ""}`, - }, + expect(JSON.parse(fs.readFileSync(resultFile, "utf8"))).toMatchObject({ + graph: "temporary-graph", + status: "clean", }); - expect(direct.status, direct.stderr).toBe(0); - expect(fs.readFileSync(nodeLog, "utf8").trim().split("\n").at(-1)).toContain( - `--report ${retainedReport} --result ${retainedResult}`, - ); - const seedEvidence = path.join(root, "seed", "reviewed-npm-audit"); - const seedHelper = path.join(root, "verify-mcporter-seed-audit.sh"); - fs.mkdirSync(seedEvidence, { recursive: true }); - fs.copyFileSync(receiptFile, path.join(seedEvidence, "mcporter-runtime.receipt.json")); - fs.writeFileSync(path.join(seedEvidence, "mcporter-runtime.raw.json"), rawReport); - fs.writeFileSync( - path.join(seedEvidence, "mcporter-runtime.receipt.sha256"), - `${createHash("sha256").update(fs.readFileSync(receiptFile)).digest("hex")}\n`, - ); - fs.writeFileSync( - seedHelper, - helperSource - .replaceAll(receiptFile, path.join(root, "missing-secret-receipt")) - .replaceAll(transportRawReport, path.join(root, "missing-secret-report")) - .replaceAll(trustedPolicyResult, path.join(root, "missing-secret-policy-result")) - .replaceAll(path.join(root, "no-seed"), seedEvidence), - { mode: 0o755 }, - ); - const rejectedSeed = spawnSync("bash", [seedHelper], { - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: "", - NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: "", - }, - }); - expect(rejectedSeed.status).not.toBe(0); - expect(rejectedSeed.stderr).toContain("build-context mcporter audit evidence is not trusted"); + fs.rmSync(resultFile); + fs.writeFileSync(auditConfigFile, JSON.stringify({ npmVersion: "11.18.0" })); + const rejected = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); + expect(rejected.status).not.toBe(0); + expect(rejected.stderr).toContain("receipt identity does not match expected graph and npm"); + expect(fs.existsSync(resultFile)).toBe(false); } finally { fs.rmSync(root, { recursive: true, force: true }); } diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index 6ca076d7667..39ef180f88f 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -30,7 +30,6 @@ let stubBin = ""; let dockerLog = ""; let dockerBuildCount = ""; let dockerBuildFailureMode = ""; -let receiptVerifyStatus = ""; let seedLog = ""; let registryCurlExit = ""; let registryLog = ""; @@ -69,7 +68,7 @@ case "$*" in ;; npm-registry-dns-once:1 | npm-registry-dns-always:1 | npm-registry-dns-always:2) printf '%s\n' '#128 0.180 ERROR: curl failed: curl: (6) Could not resolve host: registry.npmjs.org' >&2 - printf '%s\n' 'ERROR: failed to build: failed to solve: process "/bin/sh -c node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts --npm-root /usr/local/lib/node_modules/npm" did not complete successfully: exit code: 1' >&2 + printf '%s\n' 'ERROR: failed to build: failed to solve: process "/bin/sh -c node /scripts/patch-bundled-npm-tar.mts --npm-root /usr/local/lib/node_modules/npm" did not complete successfully: exit code: 1' >&2 exit 42 ;; npm-registry-dns-near-match:1) @@ -102,19 +101,8 @@ esac `#!/usr/bin/env bash set -euo pipefail printf '%s\n' "$*" >>"$NEMOCLAW_TEST_SEED_LOG" -if [[ "$*" == *"/scripts/lib/npm-audit-receipt.mts"* ]]; then - status="$NEMOCLAW_TEST_RECEIPT_VERIFY_STATUS" - result="" - while (($# > 0)); do - if [[ "$1" == "--result" ]]; then result="$2"; shift 2; else shift; fi - done - if [[ "$status" == 0 && -n "$result" ]]; then - printf '{"status":"clean"}\n' >"$result" - fi - exit "$status" -fi -mode="$4" -shift 4 +mode="$3" +shift 3 output="" while (($# > 0)); do case "$1" in @@ -170,12 +158,6 @@ function completeImportedCache(cacheRoot: string): void { writeFileSync(path.join(cacheRoot, "messaging-npm-cache-seed", "manifest.json"), "{}\n", "utf8"); } -function completeAuditEvidence(auditDirectory: string): void { - mkdirSync(auditDirectory, { recursive: true }); - writeFileSync(path.join(auditDirectory, "mcporter-runtime.receipt.json"), '{"result":"pass"}\n'); - writeFileSync(path.join(auditDirectory, "mcporter-runtime.raw.json"), '{"metadata":{}}\n'); -} - function completeSourceBoundary(sourceRoot: string): void { mkdirSync(path.join(sourceRoot, "nemoclaw"), { recursive: true }); mkdirSync(path.join(sourceRoot, "scripts", "checks"), { recursive: true }); @@ -223,9 +205,7 @@ function completeSourceBoundary(sourceRoot: string): void { function recordedBuildInvocations(): string[] { return readFileSync(dockerLog, "utf8") .split("\n") - .filter( - (line) => line.startsWith("buildx build ") && line.includes("io.nvidia.nemoclaw.agent="), - ); + .filter((line) => line.startsWith("buildx build ")); } function recordedBuildInvocation(agent: string): string { @@ -236,15 +216,11 @@ function recordedBuildInvocation(agent: string): string { return invocation!; } -function expectSingleTargetArch(agent: string, architecture: string): void { - expect( - recordedBuildInvocation(agent) - .split(" ") - .filter((argument) => argument === `TARGETARCH=${architecture}`), - ).toHaveLength(1); -} - -function runBuild(sourceRoot: string, extraArgs: readonly string[] = [], platform = "linux/amd64") { +function runBuild( + sourceRoot: string, + extraArgs: readonly string[] = [], + platform = "linux/amd64", +) { const output = path.join(testRoot, "contracts.json"); return spawnSync( "bash", @@ -280,7 +256,6 @@ function runBuild(sourceRoot: string, extraArgs: readonly string[] = [], platfor NEMOCLAW_TEST_REGISTRY_LOG: registryLog, NEMOCLAW_TEST_REGISTRY_STATUS: registryStatus, NEMOCLAW_TEST_REAL_PATH: process.env.PATH ?? "", - NEMOCLAW_TEST_RECEIPT_VERIFY_STATUS: receiptVerifyStatus, NEMOCLAW_TEST_SEED_LOG: seedLog, NEMOCLAW_TEST_TEE_FAILURE_MODE: teeFailureMode, PATH: `${stubBin}:${process.env.PATH ?? ""}`, @@ -296,7 +271,6 @@ beforeEach(() => { dockerLog = path.join(testRoot, "docker.log"); dockerBuildCount = path.join(testRoot, "docker-build-count"); dockerBuildFailureMode = ""; - receiptVerifyStatus = "0"; seedLog = path.join(testRoot, "seed.log"); registryCurlExit = "0"; registryLog = path.join(testRoot, "registry.log"); @@ -356,13 +330,13 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocation("openclaw")).toContain("--platform linux/arm64"); - expectSingleTargetArch("openclaw", "arm64"); + expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); - expectSingleTargetArch("hermes", "arm64"); + expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--platform linux/arm64"); expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( - "--platform linux/arm64", + "--build-arg TARGETARCH=arm64", ); - expectSingleTargetArch("langchain-deepagents-code", "arm64"); }); it("builds every agent without optional cache arguments", () => { @@ -394,13 +368,11 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocations()).toHaveLength(3); expect(recordedBuildInvocation("openclaw")).toContain("--platform linux/arm64"); - expectSingleTargetArch("openclaw", "arm64"); + expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); - expectSingleTargetArch("hermes", "arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( - "--platform linux/arm64", - ); - expectSingleTargetArch("langchain-deepagents-code", "arm64"); + expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--platform linux/arm64"); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--build-arg TARGETARCH=arm64"); }); it("passes each agent one empty absolute cache export root", () => { @@ -412,7 +384,6 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(existsSync(cacheRoot)).toBe(true); expect(recordedBuildInvocations()).toHaveLength(3); - expect(existsSync(path.join(cacheRoot, "reviewed-npm-audit"))).toBe(false); expect({ openclaw: recordedBuildInvocation("openclaw"), @@ -446,27 +417,6 @@ describe("protected managed-image build-cache boundary", () => { expect(existsSync(path.join(cacheRoot, "messaging-npm-cache-seed", "manifest.json"))).toBe( true, ); - expect(recordedBuildInvocation("openclaw")).not.toContain("nemoclaw-mcporter-audit"); - expect(recordedBuildInvocation("hermes")).not.toContain("nemoclaw-mcporter-audit"); - expect(recordedBuildInvocation("langchain-deepagents-code")).not.toContain( - "nemoclaw-mcporter-audit", - ); - }); - - it("cleans an incomplete cache export after a protected build fails", () => { - const cacheRoot = path.join(testRoot, "export-cache"); - stubBuildInvocation(); - dockerBuildFailureMode = "near-match"; - - const failed = runBuild(REPO_ROOT, ["--cache-to", cacheRoot]); - - expect(failed.status, failed.stderr).toBe(42); - expect(readdirSync(cacheRoot)).toEqual([]); - - dockerBuildFailureMode = ""; - const retried = runBuild(REPO_ROOT, ["--cache-to", cacheRoot]); - - expect(retried.status, retried.stderr).toBe(0); }); it.each([ @@ -546,51 +496,6 @@ describe("protected managed-image build-cache boundary", () => { expect(existsSync(dockerLog)).toBe(false); }); - it("rejects incomplete reviewed audit evidence before invoking Docker (#11088)", () => { - const cacheRoot = path.join(testRoot, "imported-cache"); - const auditRoot = path.join(testRoot, "audit-evidence"); - completeImportedCache(cacheRoot); - mkdirSync(auditRoot); - writeFileSync(path.join(auditRoot, "mcporter-runtime.receipt.json"), "", "utf8"); - stubBuildInvocation(); - - const result = runBuild(REPO_ROOT, [ - "--cache-from", - cacheRoot, - "--audit-evidence-from", - auditRoot, - ]); - - expect(result.status, result.stderr).toBe(1); - expect(result.stderr).toContain("reviewed audit evidence is incomplete"); - expect(existsSync(dockerLog)).toBe(false); - }); - - it("binds external evidence to the trusted verifier and candidate graph (#11088)", () => { - const cacheRoot = path.join(testRoot, "imported-cache"); - const auditRoot = path.join(testRoot, "audit-evidence"); - completeImportedCache(cacheRoot); - completeAuditEvidence(auditRoot); - stubBuildInvocation(); - receiptVerifyStatus = "42"; - - const result = runBuild(REPO_ROOT, [ - "--cache-from", - cacheRoot, - "--audit-evidence-from", - auditRoot, - ]); - const verification = readFileSync(seedLog, "utf8"); - - expect(result.status, result.stderr).toBe(42); - expect(verification).toContain(`${REPO_ROOT}/scripts/lib/npm-audit-receipt.mts`); - expect(verification).toContain( - `--package-json ${REPO_ROOT}/agents/openclaw/mcporter-runtime/package.json`, - ); - expect(verification).toContain(`--audit-config ${REPO_ROOT}/ci/reviewed-npm-audit.json`); - expect(existsSync(dockerLog)).toBe(false); - }); - it("imports locked seeds, reuses safe agent caches, and disables RUN network access", () => { const cacheRoot = path.join(testRoot, "imported-cache"); const sourceSeed = path.join(REPO_ROOT, "tools/mcp-tool-discovery-runtime/npm-cache-seed"); @@ -605,17 +510,10 @@ describe("protected managed-image build-cache boundary", () => { const originalSeedNames = readdirSync(sourceSeed).sort(); const originalMcpSeedNames = readdirSync(sourceMcpSeed).sort(); const originalMessagingSeedNames = readdirSync(sourceMessagingSeed).sort(); - const auditRoot = path.join(testRoot, "audit-evidence"); completeImportedCache(cacheRoot); - completeAuditEvidence(auditRoot); stubBuildInvocation(); - const result = runBuild(REPO_ROOT, [ - "--cache-from", - cacheRoot, - "--audit-evidence-from", - auditRoot, - ]); + const result = runBuild(REPO_ROOT, ["--cache-from", cacheRoot]); expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocations()).toHaveLength(3); @@ -641,21 +539,6 @@ describe("protected managed-image build-cache boundary", () => { ), }); expect(recordedBuildInvocation("openclaw").split(" ")).toContain("--no-cache"); - expect(recordedBuildInvocation("openclaw")).toContain( - `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(auditRoot)}/mcporter-runtime.receipt.json`, - ); - expect(recordedBuildInvocation("openclaw")).toContain( - `--secret id=nemoclaw-mcporter-audit-raw-report,src=${realpathSync(auditRoot)}/mcporter-runtime.raw.json`, - ); - expect(recordedBuildInvocation("openclaw")).toMatch( - /--secret id=nemoclaw-mcporter-audit-policy-result,src=\S+\/mcporter-runtime[.]policy[.]json/, - ); - expect(recordedBuildInvocation("openclaw")).toContain( - `--build-arg NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=${DIGEST}`, - ); - expect(recordedBuildInvocation("openclaw")).toContain( - `--build-arg NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256=${DIGEST}`, - ); expect(recordedBuildInvocation("hermes").split(" ")).not.toContain("--no-cache"); expect(recordedBuildInvocation("langchain-deepagents-code").split(" ")).not.toContain( "--no-cache", diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index 81eccf78d80..c0f7e7afc6b 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -47,15 +47,12 @@ const reviewedAuditDriver = fs.readFileSync( path.join(repoRoot, "scripts", "audit-reviewed-npm-graph.mts"), "utf8", ); -const mcporterAuditHelper = fs.readFileSync( - path.join(repoRoot, "scripts", "lib", "verify-mcporter-audit.sh"), - "utf8", -); + function extractIntegrityGate(contents: string): string { const startMarker = 'MCPORTER_EXPECTED_INTEGRITY=""'; const start = contents.indexOf(startMarker); const helperMarker = - "node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts --verify-only"; + "node /scripts/lib/reviewed-npm-archive.mts --verify-only"; const helperStart = contents.indexOf(helperMarker, start); const helperEndMarker = '--label "mcporter ${MCPORTER_VERSION}"'; const helperEnd = contents.indexOf(helperEndMarker, helperStart) + helperEndMarker.length; @@ -71,6 +68,19 @@ function extractIntegrityGate(contents: string): string { .trim(); } +function extractAuditReceiptInvocation(contents: string): string { + const startMarker = "node /scripts/lib/npm-audit-receipt.mts"; + const endMarker = "--legacy-npmjs true"; + const start = contents.indexOf(startMarker); + const end = contents.indexOf(endMarker, start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return contents + .slice(start, end + endMarker.length) + .replace(/\\\s*\n/g, " ") + .replace(/\s+/g, " "); +} + function runIntegrityGate(contents: string, version: string) { const script = [ "set -euo pipefail", @@ -79,14 +89,14 @@ function runIntegrityGate(contents: string, version: string) { `MCPORTER_0_7_3_TARBALL=${JSON.stringify(expectedTarball)}`, `npm() { printf '%s\\n' ${JSON.stringify(expectedIntegrity)}; }`, "node() {", - ' [ "$#" -eq 11 ] && [ "${1:-}" = "--experimental-strip-types" ] || return 81', - ' [ "${2:-}" = "/scripts/lib/reviewed-npm-archive.mts" ] && [ "${3:-}" = "--verify-only" ] || return 82', - ' [ "${4:-}" = "--package-spec" ] && [ "${5:-}" = "mcporter@${MCPORTER_VERSION}" ] || return 83', - ' [ "${6:-}" = "--integrity" ] && [ "${7:-}" = ' + + ' [ "$#" -eq 10 ] || return 81', + ' [ "${1:-}" = "/scripts/lib/reviewed-npm-archive.mts" ] && [ "${2:-}" = "--verify-only" ] || return 82', + ' [ "${3:-}" = "--package-spec" ] && [ "${4:-}" = "mcporter@${MCPORTER_VERSION}" ] || return 83', + ' [ "${5:-}" = "--integrity" ] && [ "${6:-}" = ' + `${JSON.stringify(expectedIntegrity)} ] || return 84`, - ' [ "${8:-}" = "--tarball-url" ] && [ "${9:-}" = ' + + ' [ "${7:-}" = "--tarball-url" ] && [ "${8:-}" = ' + `${JSON.stringify(expectedTarball)} ] || return 85`, - ' [ "${10:-}" = "--label" ] && [ "${11:-}" = "mcporter ${MCPORTER_VERSION}" ] || return 86', + ' [ "${9:-}" = "--label" ] && [ "${10:-}" = "mcporter ${MCPORTER_VERSION}" ] || return 86', "}", extractIntegrityGate(contents), "printf 'gate-passed\\n'", @@ -180,8 +190,8 @@ describe("mcporter image supply-chain controls", () => { }); it.each(dockerfiles)("audits the committed dependency graph in $name", ({ contents }) => { - const auditContents = `${contents}\n${mcporterAuditHelper}`; - const flattenedContents = auditContents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + const auditReceiptInvocation = extractAuditReceiptInvocation(contents); expect(contents).toContain( "COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/", ); @@ -194,24 +204,29 @@ describe("mcporter image supply-chain controls", () => { ), ).toBe(true); expect(flattenedContents).toContain( - "node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", + "node /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", ); expect(contents).toContain("ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256="); - expect(contents).toContain("ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256="); expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false", ); expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false", ); - expect(contents).toContain( - "--mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false", + expect(flattenedContents).toContain( + "node /scripts/lib/npm-audit-receipt.mts --receipt", + ); + expect(flattenedContents).toContain( + "--package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json --raw-report", + ); + expect(auditReceiptInvocation).toContain( + "--exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json --registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true", ); expect(expectedReviewedNpmVersion).toMatch(/^[0-9]+\.[0-9]+\.[0-9]+$/); - expect(auditContents).not.toContain("/scripts/lib/npm-audit-receipt.mts"); - expect(auditContents).toContain("sha256sum --check --status"); - expect(auditContents).toContain("policy_result_sha256"); - expect(auditContents).not.toContain("--raw-copy"); + expect(auditReceiptInvocation).not.toContain("--npm-version"); + expect(contents).not.toContain("--raw-copy"); + expect(auditReceiptInvocation).not.toMatch(/\bnpm\s+--version\b/); + expect(auditReceiptInvocation).not.toMatch(/\$\(|`/); expect(contents).not.toContain(`${runtimePrefix} audit --omit=dev --audit-level=low`); expect(contents).not.toContain(`${runtimePrefix} audit signatures`); expect(flattenedContents).toContain( @@ -224,11 +239,8 @@ describe("mcporter image supply-chain controls", () => { const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile.base"), "utf8"); const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); - expect(contents).toContain( - "COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh", - ); expect(flattenedContents).toContain( - "NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json bash /scripts/lib/verify-mcporter-audit.sh", + '--result /tmp/mcporter-npm-audit-policy.json && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json;', ); }); From 7b3d8416ece839654329476ba9df18cd8f638c37 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:31:58 -0700 Subject: [PATCH 44/56] merge: restore reviewed protected audit resolution Restore the reviewed resolution after GitHub created verified current-main ancestry. Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- Dockerfile | 26 +- Dockerfile.base | 29 +- .../checks/build-protected-managed-images.sh | 2 +- scripts/lib/verify-mcporter-audit.sh | 2 +- .../reviewed-npm-audit-handoff.test.ts | 351 +++++++++++++++--- ...otected-managed-image-build-script.test.ts | 147 +++++++- test/security/mcporter-supply-chain.test.ts | 46 +-- 7 files changed, 470 insertions(+), 133 deletions(-) diff --git a/Dockerfile b/Dockerfile index 46bfe54c396..5ef95afe229 100644 --- a/Dockerfile +++ b/Dockerfile @@ -544,8 +544,8 @@ COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/ COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts -COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts +COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -634,6 +634,7 @@ ARG MCPORTER_VERSION=0.7.3 ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA== ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256= +ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256= # A cross-stage root copy is accepted by Docker's legacy builder and creates one # final-image layer while preserving metadata on existing parent directories. @@ -817,9 +818,9 @@ RUN command -v codex-acp >/dev/null # OPENCLAW_VERSION is the NemoClaw runtime build target and must meet the blueprint minimum. # Reviewed archives retain registry and packed-byte SRI, basename, local-only install, and cleanup gates. # hadolint ignore=DL3059,DL4006,DL3016,SC2015 -RUN --network=default \ - --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ +RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ + --mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false \ set -eu; \ if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ @@ -983,24 +984,7 @@ RUN --network=default \ ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter; \ test "$(mcporter --version)" = "$MCPORTER_VERSION"; \ fi; \ - MCPORTER_RECEIPT=/run/secrets/nemoclaw-mcporter-audit-receipt; \ - MCPORTER_RAW_REPORT=/run/secrets/nemoclaw-mcporter-audit-raw-report; \ - if [ -f "$MCPORTER_RECEIPT" ] || [ -f "$MCPORTER_RAW_REPORT" ] || [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]; then \ - [ -f "$MCPORTER_RECEIPT" ] && [ -f "$MCPORTER_RAW_REPORT" ] && printf %s "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}' \ - || { echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2; exit 1; }; \ - printf '%s %s\n' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" "$MCPORTER_RECEIPT" | sha256sum -c -; \ -node /scripts/lib/npm-audit-receipt.mts \ ---receipt "$MCPORTER_RECEIPT" \ ---package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ ---package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ ---raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ ---graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ ---registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true; \ - else \ - node /scripts/lib/reviewed-npm-audit.mts \ - --directory /usr/local/lib/nemoclaw/mcporter-runtime \ - --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high; \ - fi + bash /scripts/lib/verify-mcporter-audit.sh # Patch OpenClaw media fetch for proxy-only sandbox (NVIDIA/NemoClaw#1755). # diff --git a/Dockerfile.base b/Dockerfile.base index 60fe775ddf4..8f17d53e4d0 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -413,6 +413,7 @@ ARG MCPORTER_VERSION=0.7.3 ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA== ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256= +ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256= # Keep paired runtime manifests and remediation helpers in grouped layers so # the published base retains its established image layout. COPY agents/openclaw/openclaw-runtime/package.json \ @@ -427,7 +428,7 @@ COPY scripts/lib/reviewed-npm-archive.mts \ scripts/lib/reviewed-npm-audit.mts \ scripts/lib/openclaw-npm-remediation.mts \ /scripts/lib/ -COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts +COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -477,6 +478,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/blueprint.yaml \ --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ + --mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false \ echo "$OPENCLAW_VERSION" | grep -qxE '[0-9]+(\.[0-9]+)*' \ || { echo "Error: OPENCLAW_VERSION='$OPENCLAW_VERSION' is invalid (expected e.g. 2026.3.11)."; exit 1; }; \ OPENCLAW_MIN_VERSION=$(grep -m 1 'min_openclaw_version' /tmp/blueprint.yaml | awk '{print $2}' | tr -d '"'); \ @@ -579,28 +581,9 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep 'const { StreamableHTTPServerTransport } = await import("file:///usr/local/lib/nemoclaw/mcporter-runtime/node_modules/@modelcontextprotocol/sdk/dist/esm/server/streamableHttp.js"); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await transport.close();' \ && ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter \ && test "$(mcporter --version)" = "$MCPORTER_VERSION" \ - && MCPORTER_RECEIPT=/run/secrets/nemoclaw-mcporter-audit-receipt \ - && MCPORTER_RAW_REPORT=/run/secrets/nemoclaw-mcporter-audit-raw-report \ - && if [ -f "$MCPORTER_RECEIPT" ] || [ -f "$MCPORTER_RAW_REPORT" ] || [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]; then \ - [ -f "$MCPORTER_RECEIPT" ] && [ -f "$MCPORTER_RAW_REPORT" ] && printf %s "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}' \ - || { echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2; exit 1; }; \ - printf '%s %s\n' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" "$MCPORTER_RECEIPT" | sha256sum -c -; \ - node /scripts/lib/npm-audit-receipt.mts \ - --receipt "$MCPORTER_RECEIPT" \ - --package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ - --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ - --raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ - --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ - --registry https://registry.yarnpkg.com --threshold high \ - --legacy-npmjs true \ - --result /tmp/mcporter-npm-audit-policy.json \ - && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json; \ - else \ - node /scripts/lib/reviewed-npm-audit.mts \ - --directory /usr/local/lib/nemoclaw/mcporter-runtime \ - --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high \ - --report /tmp/mcporter-npm-audit.json --result /tmp/mcporter-npm-audit-policy.json; \ - fi \ + && NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json \ + NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json \ + bash /scripts/lib/verify-mcporter-audit.sh \ && MCPORTER_AUDIT_STATUS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').status")" \ && MCPORTER_AUDIT_EXCEPTIONS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').acceptedAdvisories.join(',') || 'none'")" \ && MCPORTER_AUDIT_POLICY_SHA256="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').exceptionPolicySha256")" \ diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index 975d6f72493..6108e586792 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -235,7 +235,7 @@ validate_audit_evidence() { } audit_receipt_sha256="$(sha256sum "$audit_receipt" | awk '{print $1}')" audit_policy_result="$work_dir/mcporter-runtime.policy.json" - node --experimental-strip-types --no-warnings "$trusted_receipt_verifier" \ + node --no-warnings "$trusted_receipt_verifier" \ --receipt "$audit_receipt" \ --package-json "$source_root/agents/openclaw/mcporter-runtime/package.json" \ --package-lock "$source_root/agents/openclaw/mcporter-runtime/package-lock.json" \ diff --git a/scripts/lib/verify-mcporter-audit.sh b/scripts/lib/verify-mcporter-audit.sh index 7b1d071b2ba..ceb436f38ef 100755 --- a/scripts/lib/verify-mcporter-audit.sh +++ b/scripts/lib/verify-mcporter-audit.sh @@ -25,7 +25,7 @@ elif [[ -e "$seed" || -L "$seed" ]]; then echo "ERROR: build-context mcporter audit evidence is not trusted" >&2 exit 1 else - node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ + node /scripts/lib/reviewed-npm-audit.mts \ --directory /usr/local/lib/nemoclaw/mcporter-runtime \ --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high \ "${audit_output_args[@]}" diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 7e37b5d1882..38d2c128c5b 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -25,6 +25,9 @@ type Workflow = { string, { readonly steps?: readonly { + readonly name?: string; + readonly run?: string; + readonly uses?: string; readonly with?: Readonly>; }[]; } @@ -89,43 +92,143 @@ describe("reviewed npm audit handoff", () => { }, ); - it("passes producer output to the Docker receipt verifier and rejects an npm mismatch", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")); - const packageJsonFile = path.join(root, "package.json"); - const packageLockFile = path.join(root, "package-lock.json"); - const rawReportFile = path.join(root, "report.json"); - const exceptionFile = path.join(root, "exceptions.json"); - const auditConfigFile = path.join(root, "reviewed-npm-audit.json"); - const resultFile = path.join(root, "policy.json"); - const packageJson = Buffer.from("temporary manifest\n"); - const packageLock = Buffer.from("temporary lock\n"); - const exceptionPolicy = '{"schemaVersion":1,"exceptions":[]}\n'; + // source-shape-contract: security -- Every production image builder must keep the trusted three-file audit handoff atomic because GitHub and BuildKit consume these declarations directly. + it("pairs every production audit receipt with raw and trusted policy results", () => { + const managedWorkflow = YAML.parse( + fs.readFileSync(path.join(REPO_ROOT, ".github/workflows/managed-images.yaml"), "utf8"), + ) as Workflow; + const managedSteps = Object.values(managedWorkflow.jobs ?? {}).flatMap( + (job) => job.steps ?? [], + ); + const managedHandoffs = managedSteps + .map((step) => JSON.stringify(step)) + .filter((source) => source.includes("nemoclaw-mcporter-audit-receipt")); + const baseWorkflow = YAML.parse( + fs.readFileSync(path.join(REPO_ROOT, ".github/workflows/base-image-platform.yaml"), "utf8"), + ) as Workflow; + const baseHandoff = JSON.stringify( + baseWorkflow.jobs?.build?.steps?.find( + ({ name }) => name === "Build and publish platform digest", + ), + ); + const baseAction = YAML.parse( + fs.readFileSync( + path.join(REPO_ROOT, ".github/actions/build-base-image-platform/action.yaml"), + "utf8", + ), + ) as { + readonly runs?: { readonly steps?: readonly { readonly name?: string }[] }; + }; + const baseActionHandoff = JSON.stringify( + baseAction.runs?.steps?.find( + ({ name }) => name === "Build and push platform digest", + ), + ); + const baseActionValidation = JSON.stringify( + baseAction.runs?.steps?.find( + ({ name }) => name === "Validate production Docker build args", + ), + ); + const buildKitHandoffs = [...managedHandoffs, baseActionHandoff]; + + expect(managedHandoffs.length).toBeGreaterThan(0); + expect( + buildKitHandoffs.filter( + (source) => !source.includes("nemoclaw-mcporter-audit-receipt"), + ), + ).toEqual([]); + expect( + buildKitHandoffs.filter( + (source) => !source.includes("nemoclaw-mcporter-audit-raw-report"), + ), + ).toEqual([]); + expect( + buildKitHandoffs.filter( + (source) => !source.includes("nemoclaw-mcporter-audit-policy-result"), + ), + ).toEqual([]); + expect( + managedHandoffs.filter( + (source) => !source.includes("NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256"), + ), + ).toEqual([]); + expect(baseActionValidation).toContain( + "NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256", + ); + expect(baseHandoff).toContain("mcporter-audit-receipt"); + expect(baseHandoff).toContain("mcporter-audit-raw-report"); + expect(baseHandoff).toContain("mcporter-audit-policy-result"); + + const prPreparation = managedSteps.find( + ({ name, run }) => name === "Prepare same-run mcporter audit evidence" && run?.includes("trusted_root"), + ); + expect(prPreparation?.run).toContain('"$trusted_root/scripts/lib/npm-audit-receipt.mts"'); + expect(prPreparation?.run).toContain('--result "$policy"'); + expect(prPreparation?.run).not.toContain( + '"$GITHUB_WORKSPACE/scripts/lib/npm-audit-receipt.mts"', + ); + }); + + it("keeps protected audit acceptance under trusted policy and rejects forged transport", () => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")), + ); + const trustedRoot = path.join(root, "trusted"); + const targetRoot = path.join(root, "target"); + const runtime = path.join(targetRoot, "agents/openclaw/mcporter-runtime"); + const artifactDirectory = path.join(targetRoot, "artifacts/reviewed-npm-audit"); + const exceptionFile = path.join(trustedRoot, "ci/npm-audit-exceptions.json"); + const auditConfigFile = path.join(trustedRoot, "ci/reviewed-npm-audit.json"); + const auditConfig = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), + ); + const npmVersion = auditConfig.npmVersion as string; const rawReport = '{"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n'; try { - fs.writeFileSync(packageJsonFile, packageJson); - fs.writeFileSync(packageLockFile, packageLock); + fs.mkdirSync(runtime, { recursive: true }); + fs.mkdirSync(path.join(trustedRoot, "ci"), { recursive: true }); + fs.cpSync(path.join(REPO_ROOT, "scripts"), path.join(trustedRoot, "scripts"), { + recursive: true, + }); + fs.cpSync(path.join(REPO_ROOT, "agents/openclaw/mcporter-runtime"), runtime, { + recursive: true, + }); + fs.mkdirSync(path.join(targetRoot, "ci"), { recursive: true }); + fs.mkdirSync(path.join(targetRoot, "scripts", "lib"), { recursive: true }); + fs.writeFileSync(path.join(targetRoot, "ci", "reviewed-npm-audit.json"), "{}\n"); + fs.writeFileSync( + path.join(targetRoot, "scripts", "audit-reviewed-npm-graph.mts"), + "throw new Error('candidate producer executed');\n", + ); + fs.writeFileSync( + path.join(targetRoot, "scripts", "lib", "npm-audit-receipt.mts"), + "throw new Error('candidate verifier executed');\n", + ); + fs.copyFileSync(path.join(REPO_ROOT, "ci/npm-audit-exceptions.json"), exceptionFile); + fs.copyFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), auditConfigFile); + fs.mkdirSync(artifactDirectory, { recursive: true }); + const rawReportFile = path.join(artifactDirectory, "audit.json"); fs.writeFileSync(rawReportFile, rawReport); - fs.writeFileSync(exceptionFile, exceptionPolicy); - fs.writeFileSync(auditConfigFile, JSON.stringify({ npmVersion: "10.9.4" })); fs.writeFileSync( - path.join(root, "report.provenance.json"), + path.join(artifactDirectory, "audit.provenance.json"), JSON.stringify({ run: { startedAt: new Date().toISOString() } }), ); - const receiptFile = emitAuditReceipt({ - artifactDirectory: root, - graphId: "temporary-graph", - npmVersion: "10.9.4", - packageJsonFile, - packageLockFile, - preserveInputs: true, + emitAuditReceipt({ + artifactDirectory, + graphId: "mcporter-runtime", + npmVersion, + packageJsonFile: path.join(runtime, "package.json"), + packageLockFile: path.join(runtime, "package-lock.json"), rawReportFile, registryOrigin: "https://registry.yarnpkg.com", result: { acceptedAdvisories: [], blockingThreshold: "high", - exceptionPolicySha256: createHash("sha256").update(exceptionPolicy).digest("hex"), - graph: "temporary-graph", + exceptionPolicySha256: createHash("sha256") + .update(fs.readFileSync(exceptionFile)) + .digest("hex"), + graph: "mcporter-runtime", reported: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, schemaVersion: 1, status: "clean", @@ -134,11 +237,20 @@ describe("reviewed npm audit handoff", () => { threshold: "high", }); - const retainedPackageJson = path.join(root, "temporary-graph.package.json"); - const retainedPackageLock = path.join(root, "temporary-graph.package-lock.json"); - const transportRawReport = path.join(root, "temporary-graph.raw.json"); + const receiptFile = path.join(artifactDirectory, "mcporter-runtime.receipt.json"); + const retainedPackageJson = path.join(runtime, "package.json"); + const retainedPackageLock = path.join(runtime, "package-lock.json"); + const transportRawReport = path.join(artifactDirectory, "mcporter-runtime.raw.json"); + const producerPolicyResult = path.join( + artifactDirectory, + "mcporter-runtime.policy.json", + ); + const trustedPolicyResult = path.join(root, "trusted-policy-result.json"); + const receiptVerifier = path.join(trustedRoot, "scripts", "lib", "npm-audit-receipt.mts"); + const retainedReport = path.join(root, "retained-report.json"); + const retainedResult = path.join(root, "retained-result.json"); const verifierArgs = [ - path.join(REPO_ROOT, "scripts", "lib", "npm-audit-receipt.mts"), + receiptVerifier, "--receipt", receiptFile, "--package-json", @@ -150,32 +262,185 @@ describe("reviewed npm audit handoff", () => { "--exceptions", exceptionFile, "--graph", - "temporary-graph", + "mcporter-runtime", "--audit-config", auditConfigFile, "--registry", "https://registry.yarnpkg.com", "--threshold", "high", + "--legacy-npmjs", + "true", "--result", - resultFile, + trustedPolicyResult, ]; - const accepted = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); + const nodeLog = path.join(root, "node.log"); + const stubBin = path.join(root, "bin"); + const helper = path.join(root, "verify-mcporter-audit.sh"); + fs.mkdirSync(stubBin); + fs.writeFileSync( + path.join(stubBin, "node"), + '#!/usr/bin/env bash\nset -euo pipefail\nprintf \'%s\\n\' "$*" >>"$NEMOCLAW_TEST_NODE_LOG"\n[[ "$*" != *"/scripts/lib/reviewed-npm-audit.mts"* ]] || exit 0\nexec "$NEMOCLAW_TEST_REAL_NODE" "$@"\n', + { mode: 0o755 }, + ); + let helperSource = fs.readFileSync( + path.join(REPO_ROOT, "scripts/lib/verify-mcporter-audit.sh"), + "utf8", + ); + helperSource = helperSource + .replaceAll("/run/secrets/nemoclaw-mcporter-audit-receipt", receiptFile) + .replaceAll("/run/secrets/nemoclaw-mcporter-audit-raw-report", transportRawReport) + .replaceAll( + "/run/secrets/nemoclaw-mcporter-audit-policy-result", + trustedPolicyResult, + ) + .replaceAll( + "/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", + path.join(root, "no-seed"), + ); + fs.writeFileSync(helper, helperSource, { mode: 0o755 }); + const correctReceiptSha256 = createHash("sha256") + .update(fs.readFileSync(receiptFile)) + .digest("hex"); + const policyResultSha256 = () => + createHash("sha256").update(fs.readFileSync(trustedPolicyResult)).digest("hex"); + const runHelper = ( + receiptSha256 = correctReceiptSha256, + trustedPolicyResultSha256 = policyResultSha256(), + helperFile = helper, + ) => + spawnSync("bash", [helperFile], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: receiptSha256, + NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: trustedPolicyResultSha256, + NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, + NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, + NEMOCLAW_TEST_NODE_LOG: nodeLog, + NEMOCLAW_TEST_REAL_NODE: process.execPath, + PATH: `${stubBin}:${process.env.PATH ?? ""}`, + }, + }); + + fs.writeFileSync(transportRawReport, "{}\n"); + expect(JSON.parse(fs.readFileSync(producerPolicyResult, "utf8"))).toMatchObject({ + graph: "mcporter-runtime", + status: "clean", + }); + const rejectedByTrustedPolicy = spawnSync(process.execPath, verifierArgs, { + encoding: "utf8", + }); + expect(rejectedByTrustedPolicy.status).not.toBe(0); + expect(rejectedByTrustedPolicy.stderr).toContain( + "receipt rawResponseSha256 does not match", + ); + expect(fs.existsSync(trustedPolicyResult)).toBe(false); + + fs.writeFileSync(transportRawReport, rawReport); + const acceptedByTrustedPolicy = spawnSync(process.execPath, verifierArgs, { + encoding: "utf8", + }); + expect(acceptedByTrustedPolicy.status, acceptedByTrustedPolicy.stderr).toBe(0); + expect(JSON.parse(fs.readFileSync(trustedPolicyResult, "utf8"))).toMatchObject({ + graph: "mcporter-runtime", + status: "clean", + }); + + const wrongHash = "0".repeat(64); + expect(wrongHash).not.toBe(correctReceiptSha256); + const rejectedTransport = runHelper(wrongHash); + expect(rejectedTransport.status).not.toBe(0); + expect(rejectedTransport.stderr).toContain("receipt hash does not match"); + expect(fs.existsSync(retainedReport)).toBe(false); + expect(fs.existsSync(retainedResult)).toBe(false); + expect(fs.existsSync(nodeLog)).toBe(false); + + const verifiedPolicyResultSha256 = policyResultSha256(); + const forgedPolicyResult = path.join(root, "forged-policy-result.json"); + const forgedPolicyHelper = path.join(root, "verify-forged-mcporter-audit.sh"); + fs.writeFileSync(forgedPolicyResult, '{"graph":"mcporter-runtime","status":"failed"}\n'); + fs.writeFileSync( + forgedPolicyHelper, + helperSource.replaceAll(trustedPolicyResult, forgedPolicyResult), + { mode: 0o755 }, + ); + const rejectedPolicyResult = runHelper( + correctReceiptSha256, + verifiedPolicyResultSha256, + forgedPolicyHelper, + ); + expect(rejectedPolicyResult.status).not.toBe(0); + expect(rejectedPolicyResult.stderr).toContain( + "policy result hash does not match", + ); + expect(fs.existsSync(retainedReport)).toBe(false); + expect(fs.existsSync(retainedResult)).toBe(false); + expect(fs.existsSync(nodeLog)).toBe(false); + + const accepted = runHelper(); expect(accepted.status, accepted.stderr).toBe(0); - expect(fs.readFileSync(retainedPackageJson)).toEqual(packageJson); - expect(fs.readFileSync(retainedPackageLock)).toEqual(packageLock); expect(fs.readFileSync(transportRawReport, "utf8")).toBe(rawReport); - expect(JSON.parse(fs.readFileSync(resultFile, "utf8"))).toMatchObject({ - graph: "temporary-graph", - status: "clean", + expect(fs.readFileSync(retainedReport, "utf8")).toBe(rawReport); + expect(fs.readFileSync(retainedResult, "utf8")).toBe( + fs.readFileSync(trustedPolicyResult, "utf8"), + ); + expect(fs.existsSync(nodeLog)).toBe(false); + + const directHelper = path.join(root, "verify-mcporter-direct-audit.sh"); + fs.writeFileSync( + directHelper, + helperSource + .replaceAll(receiptFile, path.join(root, "missing-direct-receipt")) + .replaceAll(transportRawReport, path.join(root, "missing-direct-report")) + .replaceAll(trustedPolicyResult, path.join(root, "missing-direct-policy-result")), + { mode: 0o755 }, + ); + const direct = spawnSync("bash", [directHelper], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: "", + NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: "", + NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, + NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, + NEMOCLAW_TEST_NODE_LOG: nodeLog, + PATH: `${stubBin}:${process.env.PATH ?? ""}`, + }, }); + expect(direct.status, direct.stderr).toBe(0); + expect(fs.readFileSync(nodeLog, "utf8").trim().split("\n").at(-1)).toContain( + `--report ${retainedReport} --result ${retainedResult}`, + ); - fs.rmSync(resultFile); - fs.writeFileSync(auditConfigFile, JSON.stringify({ npmVersion: "11.18.0" })); - const rejected = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); - expect(rejected.status).not.toBe(0); - expect(rejected.stderr).toContain("receipt identity does not match expected graph and npm"); - expect(fs.existsSync(resultFile)).toBe(false); + const seedEvidence = path.join(root, "seed", "reviewed-npm-audit"); + const seedHelper = path.join(root, "verify-mcporter-seed-audit.sh"); + fs.mkdirSync(seedEvidence, { recursive: true }); + fs.copyFileSync(receiptFile, path.join(seedEvidence, "mcporter-runtime.receipt.json")); + fs.writeFileSync(path.join(seedEvidence, "mcporter-runtime.raw.json"), rawReport); + fs.writeFileSync( + path.join(seedEvidence, "mcporter-runtime.receipt.sha256"), + `${createHash("sha256").update(fs.readFileSync(receiptFile)).digest("hex")}\n`, + ); + fs.writeFileSync( + seedHelper, + helperSource + .replaceAll(receiptFile, path.join(root, "missing-secret-receipt")) + .replaceAll(transportRawReport, path.join(root, "missing-secret-report")) + .replaceAll(trustedPolicyResult, path.join(root, "missing-secret-policy-result")) + .replaceAll(path.join(root, "no-seed"), seedEvidence), + { mode: 0o755 }, + ); + const rejectedSeed = spawnSync("bash", [seedHelper], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: "", + NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: "", + }, + }); + expect(rejectedSeed.status).not.toBe(0); + expect(rejectedSeed.stderr).toContain("build-context mcporter audit evidence is not trusted"); } finally { fs.rmSync(root, { recursive: true, force: true }); } diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index 39ef180f88f..ee63210da2c 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -30,6 +30,7 @@ let stubBin = ""; let dockerLog = ""; let dockerBuildCount = ""; let dockerBuildFailureMode = ""; +let receiptVerifyStatus = ""; let seedLog = ""; let registryCurlExit = ""; let registryLog = ""; @@ -101,6 +102,17 @@ esac `#!/usr/bin/env bash set -euo pipefail printf '%s\n' "$*" >>"$NEMOCLAW_TEST_SEED_LOG" +if [[ "$*" == *"/scripts/lib/npm-audit-receipt.mts"* ]]; then + status="$NEMOCLAW_TEST_RECEIPT_VERIFY_STATUS" + result="" + while (($# > 0)); do + if [[ "$1" == "--result" ]]; then result="$2"; shift 2; else shift; fi + done + if [[ "$status" == 0 && -n "$result" ]]; then + printf '{"status":"clean"}\n' >"$result" + fi + exit "$status" +fi mode="$3" shift 3 output="" @@ -158,6 +170,12 @@ function completeImportedCache(cacheRoot: string): void { writeFileSync(path.join(cacheRoot, "messaging-npm-cache-seed", "manifest.json"), "{}\n", "utf8"); } +function completeAuditEvidence(auditDirectory: string): void { + mkdirSync(auditDirectory, { recursive: true }); + writeFileSync(path.join(auditDirectory, "mcporter-runtime.receipt.json"), '{"result":"pass"}\n'); + writeFileSync(path.join(auditDirectory, "mcporter-runtime.raw.json"), '{"metadata":{}}\n'); +} + function completeSourceBoundary(sourceRoot: string): void { mkdirSync(path.join(sourceRoot, "nemoclaw"), { recursive: true }); mkdirSync(path.join(sourceRoot, "scripts", "checks"), { recursive: true }); @@ -205,7 +223,9 @@ function completeSourceBoundary(sourceRoot: string): void { function recordedBuildInvocations(): string[] { return readFileSync(dockerLog, "utf8") .split("\n") - .filter((line) => line.startsWith("buildx build ")); + .filter( + (line) => line.startsWith("buildx build ") && line.includes("io.nvidia.nemoclaw.agent="), + ); } function recordedBuildInvocation(agent: string): string { @@ -216,11 +236,15 @@ function recordedBuildInvocation(agent: string): string { return invocation!; } -function runBuild( - sourceRoot: string, - extraArgs: readonly string[] = [], - platform = "linux/amd64", -) { +function expectSingleTargetArch(agent: string, architecture: string): void { + expect( + recordedBuildInvocation(agent) + .split(" ") + .filter((argument) => argument === `TARGETARCH=${architecture}`), + ).toHaveLength(1); +} + +function runBuild(sourceRoot: string, extraArgs: readonly string[] = [], platform = "linux/amd64") { const output = path.join(testRoot, "contracts.json"); return spawnSync( "bash", @@ -256,6 +280,7 @@ function runBuild( NEMOCLAW_TEST_REGISTRY_LOG: registryLog, NEMOCLAW_TEST_REGISTRY_STATUS: registryStatus, NEMOCLAW_TEST_REAL_PATH: process.env.PATH ?? "", + NEMOCLAW_TEST_RECEIPT_VERIFY_STATUS: receiptVerifyStatus, NEMOCLAW_TEST_SEED_LOG: seedLog, NEMOCLAW_TEST_TEE_FAILURE_MODE: teeFailureMode, PATH: `${stubBin}:${process.env.PATH ?? ""}`, @@ -271,6 +296,7 @@ beforeEach(() => { dockerLog = path.join(testRoot, "docker.log"); dockerBuildCount = path.join(testRoot, "docker-build-count"); dockerBuildFailureMode = ""; + receiptVerifyStatus = "0"; seedLog = path.join(testRoot, "seed.log"); registryCurlExit = "0"; registryLog = path.join(testRoot, "registry.log"); @@ -330,13 +356,13 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocation("openclaw")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); + expectSingleTargetArch("openclaw", "arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--platform linux/arm64"); + expectSingleTargetArch("hermes", "arm64"); expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( - "--build-arg TARGETARCH=arm64", + "--platform linux/arm64", ); + expectSingleTargetArch("langchain-deepagents-code", "arm64"); }); it("builds every agent without optional cache arguments", () => { @@ -368,11 +394,13 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocations()).toHaveLength(3); expect(recordedBuildInvocation("openclaw")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); + expectSingleTargetArch("openclaw", "arm64"); expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--platform linux/arm64"); - expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--build-arg TARGETARCH=arm64"); + expectSingleTargetArch("hermes", "arm64"); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain( + "--platform linux/arm64", + ); + expectSingleTargetArch("langchain-deepagents-code", "arm64"); }); it("passes each agent one empty absolute cache export root", () => { @@ -384,6 +412,7 @@ describe("protected managed-image build-cache boundary", () => { expect(result.status, result.stderr).toBe(0); expect(existsSync(cacheRoot)).toBe(true); expect(recordedBuildInvocations()).toHaveLength(3); + expect(existsSync(path.join(cacheRoot, "reviewed-npm-audit"))).toBe(false); expect({ openclaw: recordedBuildInvocation("openclaw"), @@ -417,6 +446,27 @@ describe("protected managed-image build-cache boundary", () => { expect(existsSync(path.join(cacheRoot, "messaging-npm-cache-seed", "manifest.json"))).toBe( true, ); + expect(recordedBuildInvocation("openclaw")).not.toContain("nemoclaw-mcporter-audit"); + expect(recordedBuildInvocation("hermes")).not.toContain("nemoclaw-mcporter-audit"); + expect(recordedBuildInvocation("langchain-deepagents-code")).not.toContain( + "nemoclaw-mcporter-audit", + ); + }); + + it("cleans an incomplete cache export after a protected build fails", () => { + const cacheRoot = path.join(testRoot, "export-cache"); + stubBuildInvocation(); + dockerBuildFailureMode = "near-match"; + + const failed = runBuild(REPO_ROOT, ["--cache-to", cacheRoot]); + + expect(failed.status, failed.stderr).toBe(42); + expect(readdirSync(cacheRoot)).toEqual([]); + + dockerBuildFailureMode = ""; + const retried = runBuild(REPO_ROOT, ["--cache-to", cacheRoot]); + + expect(retried.status, retried.stderr).toBe(0); }); it.each([ @@ -496,6 +546,51 @@ describe("protected managed-image build-cache boundary", () => { expect(existsSync(dockerLog)).toBe(false); }); + it("rejects incomplete reviewed audit evidence before invoking Docker (#11088)", () => { + const cacheRoot = path.join(testRoot, "imported-cache"); + const auditRoot = path.join(testRoot, "audit-evidence"); + completeImportedCache(cacheRoot); + mkdirSync(auditRoot); + writeFileSync(path.join(auditRoot, "mcporter-runtime.receipt.json"), "", "utf8"); + stubBuildInvocation(); + + const result = runBuild(REPO_ROOT, [ + "--cache-from", + cacheRoot, + "--audit-evidence-from", + auditRoot, + ]); + + expect(result.status, result.stderr).toBe(1); + expect(result.stderr).toContain("reviewed audit evidence is incomplete"); + expect(existsSync(dockerLog)).toBe(false); + }); + + it("binds external evidence to the trusted verifier and candidate graph (#11088)", () => { + const cacheRoot = path.join(testRoot, "imported-cache"); + const auditRoot = path.join(testRoot, "audit-evidence"); + completeImportedCache(cacheRoot); + completeAuditEvidence(auditRoot); + stubBuildInvocation(); + receiptVerifyStatus = "42"; + + const result = runBuild(REPO_ROOT, [ + "--cache-from", + cacheRoot, + "--audit-evidence-from", + auditRoot, + ]); + const verification = readFileSync(seedLog, "utf8"); + + expect(result.status, result.stderr).toBe(42); + expect(verification).toContain(`${REPO_ROOT}/scripts/lib/npm-audit-receipt.mts`); + expect(verification).toContain( + `--package-json ${REPO_ROOT}/agents/openclaw/mcporter-runtime/package.json`, + ); + expect(verification).toContain(`--audit-config ${REPO_ROOT}/ci/reviewed-npm-audit.json`); + expect(existsSync(dockerLog)).toBe(false); + }); + it("imports locked seeds, reuses safe agent caches, and disables RUN network access", () => { const cacheRoot = path.join(testRoot, "imported-cache"); const sourceSeed = path.join(REPO_ROOT, "tools/mcp-tool-discovery-runtime/npm-cache-seed"); @@ -510,10 +605,17 @@ describe("protected managed-image build-cache boundary", () => { const originalSeedNames = readdirSync(sourceSeed).sort(); const originalMcpSeedNames = readdirSync(sourceMcpSeed).sort(); const originalMessagingSeedNames = readdirSync(sourceMessagingSeed).sort(); + const auditRoot = path.join(testRoot, "audit-evidence"); completeImportedCache(cacheRoot); + completeAuditEvidence(auditRoot); stubBuildInvocation(); - const result = runBuild(REPO_ROOT, ["--cache-from", cacheRoot]); + const result = runBuild(REPO_ROOT, [ + "--cache-from", + cacheRoot, + "--audit-evidence-from", + auditRoot, + ]); expect(result.status, result.stderr).toBe(0); expect(recordedBuildInvocations()).toHaveLength(3); @@ -539,6 +641,21 @@ describe("protected managed-image build-cache boundary", () => { ), }); expect(recordedBuildInvocation("openclaw").split(" ")).toContain("--no-cache"); + expect(recordedBuildInvocation("openclaw")).toContain( + `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(auditRoot)}/mcporter-runtime.receipt.json`, + ); + expect(recordedBuildInvocation("openclaw")).toContain( + `--secret id=nemoclaw-mcporter-audit-raw-report,src=${realpathSync(auditRoot)}/mcporter-runtime.raw.json`, + ); + expect(recordedBuildInvocation("openclaw")).toMatch( + /--secret id=nemoclaw-mcporter-audit-policy-result,src=\S+\/mcporter-runtime[.]policy[.]json/, + ); + expect(recordedBuildInvocation("openclaw")).toContain( + `--build-arg NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=${DIGEST}`, + ); + expect(recordedBuildInvocation("openclaw")).toContain( + `--build-arg NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256=${DIGEST}`, + ); expect(recordedBuildInvocation("hermes").split(" ")).not.toContain("--no-cache"); expect(recordedBuildInvocation("langchain-deepagents-code").split(" ")).not.toContain( "--no-cache", diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index c0f7e7afc6b..1ae920e5179 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -47,7 +47,10 @@ const reviewedAuditDriver = fs.readFileSync( path.join(repoRoot, "scripts", "audit-reviewed-npm-graph.mts"), "utf8", ); - +const mcporterAuditHelper = fs.readFileSync( + path.join(repoRoot, "scripts", "lib", "verify-mcporter-audit.sh"), + "utf8", +); function extractIntegrityGate(contents: string): string { const startMarker = 'MCPORTER_EXPECTED_INTEGRITY=""'; const start = contents.indexOf(startMarker); @@ -68,19 +71,6 @@ function extractIntegrityGate(contents: string): string { .trim(); } -function extractAuditReceiptInvocation(contents: string): string { - const startMarker = "node /scripts/lib/npm-audit-receipt.mts"; - const endMarker = "--legacy-npmjs true"; - const start = contents.indexOf(startMarker); - const end = contents.indexOf(endMarker, start); - expect(start).toBeGreaterThanOrEqual(0); - expect(end).toBeGreaterThan(start); - return contents - .slice(start, end + endMarker.length) - .replace(/\\\s*\n/g, " ") - .replace(/\s+/g, " "); -} - function runIntegrityGate(contents: string, version: string) { const script = [ "set -euo pipefail", @@ -190,8 +180,8 @@ describe("mcporter image supply-chain controls", () => { }); it.each(dockerfiles)("audits the committed dependency graph in $name", ({ contents }) => { - const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); - const auditReceiptInvocation = extractAuditReceiptInvocation(contents); + const auditContents = `${contents}\n${mcporterAuditHelper}`; + const flattenedContents = auditContents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); expect(contents).toContain( "COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/", ); @@ -207,26 +197,21 @@ describe("mcporter image supply-chain controls", () => { "node /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", ); expect(contents).toContain("ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256="); + expect(contents).toContain("ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256="); expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false", ); expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false", ); - expect(flattenedContents).toContain( - "node /scripts/lib/npm-audit-receipt.mts --receipt", - ); - expect(flattenedContents).toContain( - "--package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json --raw-report", - ); - expect(auditReceiptInvocation).toContain( - "--exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json --registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true", + expect(contents).toContain( + "--mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false", ); expect(expectedReviewedNpmVersion).toMatch(/^[0-9]+\.[0-9]+\.[0-9]+$/); - expect(auditReceiptInvocation).not.toContain("--npm-version"); - expect(contents).not.toContain("--raw-copy"); - expect(auditReceiptInvocation).not.toMatch(/\bnpm\s+--version\b/); - expect(auditReceiptInvocation).not.toMatch(/\$\(|`/); + expect(auditContents).not.toContain("/scripts/lib/npm-audit-receipt.mts"); + expect(auditContents).toContain("sha256sum --check --status"); + expect(auditContents).toContain("policy_result_sha256"); + expect(auditContents).not.toContain("--raw-copy"); expect(contents).not.toContain(`${runtimePrefix} audit --omit=dev --audit-level=low`); expect(contents).not.toContain(`${runtimePrefix} audit signatures`); expect(flattenedContents).toContain( @@ -239,8 +224,11 @@ describe("mcporter image supply-chain controls", () => { const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile.base"), "utf8"); const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + expect(contents).toContain( + "COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh", + ); expect(flattenedContents).toContain( - '--result /tmp/mcporter-npm-audit-policy.json && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json;', + "NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json bash /scripts/lib/verify-mcporter-audit.sh", ); }); From 1b2805e9751f9ff63c43f7d534fc5e9918ab7798 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter Date: Wed, 9 Sep 2026 12:53:30 -0700 Subject: [PATCH 45/56] test(security): align audit fixture with Node 22 The production audit helper now runs through Node native type stripping. Keep the Dockerfile regression fixture on the same invocation so its node shim observes and validates the reviewed audit call.\n\nSigned-off-by: Rebecca Sliter --- test/security/fetch-guard-patch-regression.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/security/fetch-guard-patch-regression.test.ts b/test/security/fetch-guard-patch-regression.test.ts index 29e3ecc9559..8d4b3d2dd14 100644 --- a/test/security/fetch-guard-patch-regression.test.ts +++ b/test/security/fetch-guard-patch-regression.test.ts @@ -188,7 +188,7 @@ function runOpenClawUpgradeBlock(currentVersion: string) { ) .replaceAll( "bash /scripts/lib/verify-mcporter-audit.sh", - "node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", + "node /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", ) .replaceAll("/opt/nemoclaw-blueprint/blueprint.yaml", blueprint) .replaceAll("/usr/local/lib/node_modules/openclaw", openclawInstall) From a6aead6180bfcae5befaf114071891ec96af9633 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 9 Sep 2026 15:50:05 -0400 Subject: [PATCH 46/56] fix(ci): complete reviewed audit follow-up Clarify audit evidence ownership and assert the complete direct fallback invocation. Refresh the reviewed runtime hash after integrating current main. Signed-off-by: Julie Yaunches Co-authored-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- Dockerfile | 26 +++- agents/openclaw/dependency-review.md | 2 +- .../reviewed-npm-audit-handoff.test.ts | 133 ++++++++++++------ .../mcp-tool-discovery-image-contract.test.ts | 2 +- ...otected-managed-image-build-script.test.ts | 79 +++++++++++ 5 files changed, 193 insertions(+), 49 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5ef95afe229..37bf90d19f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -866,11 +866,22 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ MCPORTER_LOCK_SHA256="$(sha256sum /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json | awk '{print $1}')"; \ [ -n "$MCPORTER_LOCK_SHA256" ] \ || { echo "ERROR: Could not hash the committed mcporter lockfile" >&2; exit 1; }; \ - MCPORTER_AUDIT_POLICY_SHA256="$(sha256sum /scripts/npm-audit-exceptions.json | awk '{print $1}')"; \ - MCPORTER_EXPECTED_AUDIT_EXCEPTIONS="$(node --input-type=module -e \ - 'import fs from "node:fs"; import { parseAuditExceptionRegistry } from "/scripts/lib/reviewed-npm-audit.mts"; const policy=parseAuditExceptionRegistry(fs.readFileSync("/scripts/npm-audit-exceptions.json", "utf-8")); const ids=policy.exceptions.filter((entry)=>entry.graph==="mcporter-runtime").map((entry)=>entry.advisory).sort(); process.stdout.write(ids.join(",") || "none");')"; \ - MCPORTER_EXPECTED_AUDIT_STATUS=clean; \ - if [ "$MCPORTER_EXPECTED_AUDIT_EXCEPTIONS" != "none" ]; then MCPORTER_EXPECTED_AUDIT_STATUS=accepted-exceptions; fi; \ + MCPORTER_AUDIT_EVIDENCE=0; \ + if [ -n "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256$NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256" ]; then \ + NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json \ + NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json \ + bash /scripts/lib/verify-mcporter-audit.sh; \ + MCPORTER_AUDIT_EVIDENCE=1; \ + MCPORTER_AUDIT_POLICY_SHA256="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').exceptionPolicySha256")"; \ + MCPORTER_EXPECTED_AUDIT_EXCEPTIONS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').acceptedAdvisories.join(',') || 'none'")"; \ + MCPORTER_EXPECTED_AUDIT_STATUS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').status")"; \ + else \ + MCPORTER_AUDIT_POLICY_SHA256="$(sha256sum /scripts/npm-audit-exceptions.json | awk '{print $1}')"; \ + MCPORTER_EXPECTED_AUDIT_EXCEPTIONS="$(node --input-type=module -e \ + 'import fs from "node:fs"; import { parseAuditExceptionRegistry } from "/scripts/lib/reviewed-npm-audit.mts"; const policy=parseAuditExceptionRegistry(fs.readFileSync("/scripts/npm-audit-exceptions.json", "utf-8")); const ids=policy.exceptions.filter((entry)=>entry.graph==="mcporter-runtime").map((entry)=>entry.advisory).sort(); process.stdout.write(ids.join(",") || "none");')"; \ + MCPORTER_EXPECTED_AUDIT_STATUS=clean; \ + if [ "$MCPORTER_EXPECTED_AUDIT_EXCEPTIONS" != "none" ]; then MCPORTER_EXPECTED_AUDIT_STATUS=accepted-exceptions; fi; \ + fi; \ CUR_VER_OUTPUT="$(openclaw --version 2>/dev/null)" \ || { echo "ERROR: Could not execute openclaw --version" >&2; exit 1; }; \ CUR_VER="$(printf '%s\n' "$CUR_VER_OUTPUT" | /usr/local/lib/nemoclaw/extract-semver openclaw)" \ @@ -984,7 +995,10 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter; \ test "$(mcporter --version)" = "$MCPORTER_VERSION"; \ fi; \ - bash /scripts/lib/verify-mcporter-audit.sh + if [ "$MCPORTER_AUDIT_EVIDENCE" = 0 ]; then \ + bash /scripts/lib/verify-mcporter-audit.sh; \ + fi; \ + rm -f /tmp/mcporter-npm-audit.json /tmp/mcporter-npm-audit-policy.json # Patch OpenClaw media fetch for proxy-only sandbox (NVIDIA/NemoClaw#1755). # diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index ec4d7be2c31..4df23e3daef 100644 --- a/agents/openclaw/dependency-review.md +++ b/agents/openclaw/dependency-review.md @@ -28,7 +28,7 @@ Update it and `agents/openclaw/mcporter-runtime/package*.json` together whenever Both image paths install the committed graph with `npm ci --ignore-scripts --omit=dev` because the published package declares no install-time lifecycle script and NemoClaw needs only its already-built CLI. The reviewed audit wrapper reports lower-severity production findings and blocks unaccepted high or critical advisories. The default `ci/npm-audit-exceptions.json` registry is empty. Any future exception must match one advisory, graph, package, installed version, and severity; identify an owner and NemoClaw tracking issue; state a decision, rationale, and expiry no more than 30 days away; and include compensating controls for temporary risk acceptance. Missing, malformed, expired, overlong, mismatched, or unused exceptions fail closed. The repository-wide audit also rejects exceptions for unknown graph IDs. Registry signature verification remains a separate control. -Protected runtime qualification supplies the mcporter receipt, raw report, and trusted policy result as BuildKit secrets. Before its offline build, the protected job runs the existing trusted audit action against the candidate inputs and verifies the resulting evidence with policy from the trusted workflow checkout. Standard trusted base- and managed-image publication carries the audit producer's named policy result with the same receipt and raw report. The action reuses matching, unexpired audit records or refreshes them through the configured registry. Offline consumers check the receipt and policy result transport hashes before retaining the trusted result. Other image builds without protected evidence run the reviewed audit directly and fail closed if completeness cannot be established. +For pull requests, the required audit job produces the mcporter receipt, raw report, and trusted policy result. The managed-image build job verifies that evidence against candidate inputs with the pull request base SHA verifier before it builds. Protected runtime qualification runs the trusted audit action against candidate inputs before its offline build and supplies the same three files as BuildKit secrets. Standard trusted base- and managed-image publication carries the audit producer's named policy result with the same receipt and raw report. The action reuses matching, unexpired audit records or refreshes them through the configured registry. Offline consumers check the receipt and policy result transport hashes before retaining the trusted result. Evidence-backed final image reuse derives its policy provenance from that retained trusted result; the candidate exception registry supplies provenance only for builds that perform the audit directly. Other image builds without protected evidence run the reviewed audit directly and fail closed if completeness cannot be established. ## WeChat plugin runtime graph diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 38d2c128c5b..32a40ccdf0a 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -120,27 +120,19 @@ describe("reviewed npm audit handoff", () => { readonly runs?: { readonly steps?: readonly { readonly name?: string }[] }; }; const baseActionHandoff = JSON.stringify( - baseAction.runs?.steps?.find( - ({ name }) => name === "Build and push platform digest", - ), + baseAction.runs?.steps?.find(({ name }) => name === "Build and push platform digest"), ); const baseActionValidation = JSON.stringify( - baseAction.runs?.steps?.find( - ({ name }) => name === "Validate production Docker build args", - ), + baseAction.runs?.steps?.find(({ name }) => name === "Validate production Docker build args"), ); const buildKitHandoffs = [...managedHandoffs, baseActionHandoff]; expect(managedHandoffs.length).toBeGreaterThan(0); expect( - buildKitHandoffs.filter( - (source) => !source.includes("nemoclaw-mcporter-audit-receipt"), - ), + buildKitHandoffs.filter((source) => !source.includes("nemoclaw-mcporter-audit-receipt")), ).toEqual([]); expect( - buildKitHandoffs.filter( - (source) => !source.includes("nemoclaw-mcporter-audit-raw-report"), - ), + buildKitHandoffs.filter((source) => !source.includes("nemoclaw-mcporter-audit-raw-report")), ).toEqual([]); expect( buildKitHandoffs.filter( @@ -152,21 +144,33 @@ describe("reviewed npm audit handoff", () => { (source) => !source.includes("NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256"), ), ).toEqual([]); - expect(baseActionValidation).toContain( - "NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256", - ); + expect(baseActionValidation).toContain("NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256"); expect(baseHandoff).toContain("mcporter-audit-receipt"); expect(baseHandoff).toContain("mcporter-audit-raw-report"); expect(baseHandoff).toContain("mcporter-audit-policy-result"); const prPreparation = managedSteps.find( - ({ name, run }) => name === "Prepare same-run mcporter audit evidence" && run?.includes("trusted_root"), + ({ name, run }) => + name === "Prepare same-run mcporter audit evidence" && run?.includes("trusted_root"), ); expect(prPreparation?.run).toContain('"$trusted_root/scripts/lib/npm-audit-receipt.mts"'); expect(prPreparation?.run).toContain('--result "$policy"'); expect(prPreparation?.run).not.toContain( '"$GITHUB_WORKSPACE/scripts/lib/npm-audit-receipt.mts"', ); + const dockerfile = fs.readFileSync(path.join(REPO_ROOT, "Dockerfile"), "utf8"); + const trustedPolicyRead = dockerfile.indexOf( + "require('/tmp/mcporter-npm-audit-policy.json').exceptionPolicySha256", + ); + const provenanceWrite = dockerfile.indexOf( + '"mcporter-audit-policy-sha256=${MCPORTER_AUDIT_POLICY_SHA256}"', + ); + expect(trustedPolicyRead).toBeGreaterThan(-1); + expect(provenanceWrite).toBeGreaterThan(trustedPolicyRead); + expect(dockerfile).toContain( + "require('/tmp/mcporter-npm-audit-policy.json').acceptedAdvisories.join(',')", + ); + expect(dockerfile).toContain("require('/tmp/mcporter-npm-audit-policy.json').status"); }); it("keeps protected audit acceptance under trusted policy and rejects forged transport", () => { @@ -183,8 +187,33 @@ describe("reviewed npm audit handoff", () => { fs.readFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), ); const npmVersion = auditConfig.npmVersion as string; - const rawReport = - '{"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n'; + const acceptedAdvisory = "GHSA-aaaa-bbbb-cccc"; + const rawReport = `${JSON.stringify({ + auditReportVersion: 2, + vulnerabilities: { + "vulnerable-package": { + effects: [], + isDirect: true, + name: "vulnerable-package", + nodes: ["node_modules/vulnerable-package"], + severity: "high", + via: [ + { + dependency: "vulnerable-package", + name: "vulnerable-package", + range: "<=1.0.0", + severity: "high", + source: 123456, + title: "test advisory", + url: `https://github.com/advisories/${acceptedAdvisory}`, + }, + ], + }, + }, + metadata: { + vulnerabilities: { info: 0, low: 0, moderate: 0, high: 1, critical: 0 }, + }, + })}\n`; try { fs.mkdirSync(runtime, { recursive: true }); fs.mkdirSync(path.join(trustedRoot, "ci"), { recursive: true }); @@ -194,9 +223,20 @@ describe("reviewed npm audit handoff", () => { fs.cpSync(path.join(REPO_ROOT, "agents/openclaw/mcporter-runtime"), runtime, { recursive: true, }); + fs.mkdirSync(path.join(runtime, "node_modules", "vulnerable-package"), { + recursive: true, + }); + fs.writeFileSync( + path.join(runtime, "node_modules", "vulnerable-package", "package.json"), + '{"name":"vulnerable-package","version":"1.0.0"}\n', + ); fs.mkdirSync(path.join(targetRoot, "ci"), { recursive: true }); fs.mkdirSync(path.join(targetRoot, "scripts", "lib"), { recursive: true }); fs.writeFileSync(path.join(targetRoot, "ci", "reviewed-npm-audit.json"), "{}\n"); + fs.writeFileSync( + path.join(targetRoot, "ci", "npm-audit-exceptions.json"), + '{"schemaVersion":1,"exceptions":[]}\n', + ); fs.writeFileSync( path.join(targetRoot, "scripts", "audit-reviewed-npm-graph.mts"), "throw new Error('candidate producer executed');\n", @@ -205,7 +245,27 @@ describe("reviewed npm audit handoff", () => { path.join(targetRoot, "scripts", "lib", "npm-audit-receipt.mts"), "throw new Error('candidate verifier executed');\n", ); - fs.copyFileSync(path.join(REPO_ROOT, "ci/npm-audit-exceptions.json"), exceptionFile); + fs.writeFileSync( + exceptionFile, + `${JSON.stringify({ + schemaVersion: 1, + exceptions: [ + { + advisory: acceptedAdvisory, + compensatingControls: ["The vulnerable input is rejected before use."], + decision: "temporary-risk-acceptance", + expires: "2026-09-16", + graph: "mcporter-runtime", + installedVersion: "1.0.0", + owner: "security-maintainers", + package: "vulnerable-package", + rationale: "The fix is in validation.", + severity: "high", + trackingIssue: "https://github.com/NVIDIA/NemoClaw/issues/11088", + }, + ], + })}\n`, + ); fs.copyFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), auditConfigFile); fs.mkdirSync(artifactDirectory, { recursive: true }); const rawReportFile = path.join(artifactDirectory, "audit.json"); @@ -223,15 +283,15 @@ describe("reviewed npm audit handoff", () => { rawReportFile, registryOrigin: "https://registry.yarnpkg.com", result: { - acceptedAdvisories: [], + acceptedAdvisories: [acceptedAdvisory], blockingThreshold: "high", exceptionPolicySha256: createHash("sha256") .update(fs.readFileSync(exceptionFile)) .digest("hex"), graph: "mcporter-runtime", - reported: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, + reported: { info: 0, low: 0, moderate: 0, high: 1, critical: 0 }, schemaVersion: 1, - status: "clean", + status: "accepted-exceptions", unacceptedBlockingAdvisories: [], }, threshold: "high", @@ -241,10 +301,7 @@ describe("reviewed npm audit handoff", () => { const retainedPackageJson = path.join(runtime, "package.json"); const retainedPackageLock = path.join(runtime, "package-lock.json"); const transportRawReport = path.join(artifactDirectory, "mcporter-runtime.raw.json"); - const producerPolicyResult = path.join( - artifactDirectory, - "mcporter-runtime.policy.json", - ); + const producerPolicyResult = path.join(artifactDirectory, "mcporter-runtime.policy.json"); const trustedPolicyResult = path.join(root, "trusted-policy-result.json"); const receiptVerifier = path.join(trustedRoot, "scripts", "lib", "npm-audit-receipt.mts"); const retainedReport = path.join(root, "retained-report.json"); @@ -290,10 +347,7 @@ describe("reviewed npm audit handoff", () => { helperSource = helperSource .replaceAll("/run/secrets/nemoclaw-mcporter-audit-receipt", receiptFile) .replaceAll("/run/secrets/nemoclaw-mcporter-audit-raw-report", transportRawReport) - .replaceAll( - "/run/secrets/nemoclaw-mcporter-audit-policy-result", - trustedPolicyResult, - ) + .replaceAll("/run/secrets/nemoclaw-mcporter-audit-policy-result", trustedPolicyResult) .replaceAll( "/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", path.join(root, "no-seed"), @@ -325,16 +379,15 @@ describe("reviewed npm audit handoff", () => { fs.writeFileSync(transportRawReport, "{}\n"); expect(JSON.parse(fs.readFileSync(producerPolicyResult, "utf8"))).toMatchObject({ + acceptedAdvisories: [acceptedAdvisory], graph: "mcporter-runtime", - status: "clean", + status: "accepted-exceptions", }); const rejectedByTrustedPolicy = spawnSync(process.execPath, verifierArgs, { encoding: "utf8", }); expect(rejectedByTrustedPolicy.status).not.toBe(0); - expect(rejectedByTrustedPolicy.stderr).toContain( - "receipt rawResponseSha256 does not match", - ); + expect(rejectedByTrustedPolicy.stderr).toContain("receipt rawResponseSha256 does not match"); expect(fs.existsSync(trustedPolicyResult)).toBe(false); fs.writeFileSync(transportRawReport, rawReport); @@ -343,8 +396,9 @@ describe("reviewed npm audit handoff", () => { }); expect(acceptedByTrustedPolicy.status, acceptedByTrustedPolicy.stderr).toBe(0); expect(JSON.parse(fs.readFileSync(trustedPolicyResult, "utf8"))).toMatchObject({ + acceptedAdvisories: [acceptedAdvisory], graph: "mcporter-runtime", - status: "clean", + status: "accepted-exceptions", }); const wrongHash = "0".repeat(64); @@ -371,9 +425,7 @@ describe("reviewed npm audit handoff", () => { forgedPolicyHelper, ); expect(rejectedPolicyResult.status).not.toBe(0); - expect(rejectedPolicyResult.stderr).toContain( - "policy result hash does not match", - ); + expect(rejectedPolicyResult.stderr).toContain("policy result hash does not match"); expect(fs.existsSync(retainedReport)).toBe(false); expect(fs.existsSync(retainedResult)).toBe(false); expect(fs.existsSync(nodeLog)).toBe(false); @@ -386,7 +438,6 @@ describe("reviewed npm audit handoff", () => { fs.readFileSync(trustedPolicyResult, "utf8"), ); expect(fs.existsSync(nodeLog)).toBe(false); - const directHelper = path.join(root, "verify-mcporter-direct-audit.sh"); fs.writeFileSync( directHelper, @@ -409,8 +460,8 @@ describe("reviewed npm audit handoff", () => { }, }); expect(direct.status, direct.stderr).toBe(0); - expect(fs.readFileSync(nodeLog, "utf8").trim().split("\n").at(-1)).toContain( - `--report ${retainedReport} --result ${retainedResult}`, + expect(fs.readFileSync(nodeLog, "utf8").trim().split("\n").at(-1)).toBe( + `/scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high --report ${retainedReport} --result ${retainedResult}`, ); const seedEvidence = path.join(root, "seed", "reviewed-npm-audit"); diff --git a/test/mcp/mcp-tool-discovery-image-contract.test.ts b/test/mcp/mcp-tool-discovery-image-contract.test.ts index 14b9136a3c4..25008b85774 100644 --- a/test/mcp/mcp-tool-discovery-image-contract.test.ts +++ b/test/mcp/mcp-tool-discovery-image-contract.test.ts @@ -15,7 +15,7 @@ const repoRoot = path.join(import.meta.dirname, "../.."); const runtimeRoot = "/usr/local/lib/nemoclaw/mcp-tool-discovery-runtime"; const managedStartupRuntimeBundle = "managed-startup-image-runtime.bundle"; const reviewedRuntimeHashOverrides: Readonly> = { - [managedStartupRuntimeBundle]: "c267456af3ef655f344eea46caa0f23f93b33c88df8b5c290d7fad174346f04c", + [managedStartupRuntimeBundle]: "17ac7309b4f830947e0fcf88999c2e7b7e95cd67f880c3f6fccfac0aca2aeb6b", }; const dockerfiles = [ "Dockerfile", diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index ee63210da2c..d016b19751f 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { chmodSync, existsSync, @@ -19,6 +20,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { emitAuditReceipt } from "../../../scripts/audit-reviewed-npm-graph.mts"; const REPO_ROOT = path.resolve(fileURLToPath(new URL("../../..", import.meta.url))); const SCRIPT = path.join(REPO_ROOT, "scripts/checks/build-protected-managed-images.sh"); @@ -104,6 +106,9 @@ set -euo pipefail printf '%s\n' "$*" >>"$NEMOCLAW_TEST_SEED_LOG" if [[ "$*" == *"/scripts/lib/npm-audit-receipt.mts"* ]]; then status="$NEMOCLAW_TEST_RECEIPT_VERIFY_STATUS" + if [[ "$status" == real ]]; then + exec "$NEMOCLAW_TEST_REAL_NODE" "$@" + fi result="" while (($# > 0)); do if [[ "$1" == "--result" ]]; then result="$2"; shift 2; else shift; fi @@ -176,6 +181,45 @@ function completeAuditEvidence(auditDirectory: string): void { writeFileSync(path.join(auditDirectory, "mcporter-runtime.raw.json"), '{"metadata":{}}\n'); } +function completeValidAuditEvidence(auditDirectory: string): void { + const auditConfig = JSON.parse( + readFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), + ) as { readonly npmVersion: string }; + const exceptionFile = path.join(REPO_ROOT, "ci/npm-audit-exceptions.json"); + const rawReportFile = path.join(auditDirectory, "audit.json"); + mkdirSync(auditDirectory, { recursive: true }); + writeFileSync( + rawReportFile, + '{"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n', + "utf8", + ); + writeFileSync( + path.join(auditDirectory, "audit.provenance.json"), + `${JSON.stringify({ run: { startedAt: new Date().toISOString() } })}\n`, + "utf8", + ); + emitAuditReceipt({ + artifactDirectory: auditDirectory, + graphId: "mcporter-runtime", + npmVersion: auditConfig.npmVersion, + packageJsonFile: path.join(REPO_ROOT, "agents/openclaw/mcporter-runtime/package.json"), + packageLockFile: path.join(REPO_ROOT, "agents/openclaw/mcporter-runtime/package-lock.json"), + rawReportFile, + registryOrigin: "https://registry.yarnpkg.com", + result: { + acceptedAdvisories: [], + blockingThreshold: "high", + exceptionPolicySha256: createHash("sha256").update(readFileSync(exceptionFile)).digest("hex"), + graph: "mcporter-runtime", + reported: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, + schemaVersion: 1, + status: "clean", + unacceptedBlockingAdvisories: [], + }, + threshold: "high", + }); +} + function completeSourceBoundary(sourceRoot: string): void { mkdirSync(path.join(sourceRoot, "nemoclaw"), { recursive: true }); mkdirSync(path.join(sourceRoot, "scripts", "checks"), { recursive: true }); @@ -281,6 +325,7 @@ function runBuild(sourceRoot: string, extraArgs: readonly string[] = [], platfor NEMOCLAW_TEST_REGISTRY_STATUS: registryStatus, NEMOCLAW_TEST_REAL_PATH: process.env.PATH ?? "", NEMOCLAW_TEST_RECEIPT_VERIFY_STATUS: receiptVerifyStatus, + NEMOCLAW_TEST_REAL_NODE: process.execPath, NEMOCLAW_TEST_SEED_LOG: seedLog, NEMOCLAW_TEST_TEE_FAILURE_MODE: teeFailureMode, PATH: `${stubBin}:${process.env.PATH ?? ""}`, @@ -591,6 +636,40 @@ describe("protected managed-image build-cache boundary", () => { expect(existsSync(dockerLog)).toBe(false); }); + it("passes valid external evidence through the real trusted verifier (#11088)", () => { + const cacheRoot = path.join(testRoot, "imported-cache"); + const auditRoot = path.join(testRoot, "audit-evidence"); + completeImportedCache(cacheRoot); + completeValidAuditEvidence(auditRoot); + stubBuildInvocation(); + receiptVerifyStatus = "real"; + + const result = runBuild(REPO_ROOT, [ + "--cache-from", + cacheRoot, + "--audit-evidence-from", + auditRoot, + ]); + + expect(result.status, result.stderr).toBe(0); + expect(recordedBuildInvocations()).toHaveLength(3); + expect(recordedBuildInvocation("openclaw")).toContain( + `--secret id=nemoclaw-mcporter-audit-receipt,src=${realpathSync(auditRoot)}/mcporter-runtime.receipt.json`, + ); + expect(recordedBuildInvocation("openclaw")).toContain( + `--secret id=nemoclaw-mcporter-audit-raw-report,src=${realpathSync(auditRoot)}/mcporter-runtime.raw.json`, + ); + expect(recordedBuildInvocation("openclaw")).toMatch( + /--secret id=nemoclaw-mcporter-audit-policy-result,src=\S+\/mcporter-runtime[.]policy[.]json/, + ); + expect(recordedBuildInvocation("openclaw")).toContain( + `--build-arg NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=${DIGEST}`, + ); + expect(recordedBuildInvocation("openclaw")).toContain( + `--build-arg NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256=${DIGEST}`, + ); + }); + it("imports locked seeds, reuses safe agent caches, and disables RUN network access", () => { const cacheRoot = path.join(testRoot, "imported-cache"); const sourceSeed = path.join(REPO_ROOT, "tools/mcp-tool-discovery-runtime/npm-cache-seed"); From d0b70f747def7e30c872e303cc06fa84d35fc356 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter Date: Wed, 9 Sep 2026 17:14:45 -0400 Subject: [PATCH 47/56] test(security): align audit fixture with Node 22 The production audit helper now uses Node native type stripping. Keep the Dockerfile regression fixture on that invocation. Its node shim then validates the reviewed audit call. Signed-off-by: Rebecca Sliter --- test/security/fetch-guard-patch-regression.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/security/fetch-guard-patch-regression.test.ts b/test/security/fetch-guard-patch-regression.test.ts index 29e3ecc9559..8d4b3d2dd14 100644 --- a/test/security/fetch-guard-patch-regression.test.ts +++ b/test/security/fetch-guard-patch-regression.test.ts @@ -188,7 +188,7 @@ function runOpenClawUpgradeBlock(currentVersion: string) { ) .replaceAll( "bash /scripts/lib/verify-mcporter-audit.sh", - "node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", + "node /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", ) .replaceAll("/opt/nemoclaw-blueprint/blueprint.yaml", blueprint) .replaceAll("/usr/local/lib/node_modules/openclaw", openclawInstall) From 2a88fc3183f375c8976fd9eaf54f4be52743fcf5 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 9 Sep 2026 17:16:12 -0400 Subject: [PATCH 48/56] fix(build): tolerate omitted audit hash args Treat absent BuildKit audit hash arguments as an empty handoff. Direct builds and extracted shell contract tests retain the fail-closed fallback. Signed-off-by: Julie Yaunches --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 37bf90d19f3..4f8760f5236 100644 --- a/Dockerfile +++ b/Dockerfile @@ -867,7 +867,7 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ [ -n "$MCPORTER_LOCK_SHA256" ] \ || { echo "ERROR: Could not hash the committed mcporter lockfile" >&2; exit 1; }; \ MCPORTER_AUDIT_EVIDENCE=0; \ - if [ -n "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256$NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256" ]; then \ + if [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}${NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256:-}" ]; then \ NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json \ NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json \ bash /scripts/lib/verify-mcporter-audit.sh; \ From cf181d46f22bd733affd53016d5cc86e50b484f3 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 9 Sep 2026 17:37:15 -0400 Subject: [PATCH 49/56] docs(audit): split handoff trust boundaries Present the producer and consumer responsibilities as short statements. Do the same for provenance and fallback behavior. Signed-off-by: Julie Yaunches --- agents/openclaw/dependency-review.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index 4df23e3daef..03d91fadca2 100644 --- a/agents/openclaw/dependency-review.md +++ b/agents/openclaw/dependency-review.md @@ -28,7 +28,17 @@ Update it and `agents/openclaw/mcporter-runtime/package*.json` together whenever Both image paths install the committed graph with `npm ci --ignore-scripts --omit=dev` because the published package declares no install-time lifecycle script and NemoClaw needs only its already-built CLI. The reviewed audit wrapper reports lower-severity production findings and blocks unaccepted high or critical advisories. The default `ci/npm-audit-exceptions.json` registry is empty. Any future exception must match one advisory, graph, package, installed version, and severity; identify an owner and NemoClaw tracking issue; state a decision, rationale, and expiry no more than 30 days away; and include compensating controls for temporary risk acceptance. Missing, malformed, expired, overlong, mismatched, or unused exceptions fail closed. The repository-wide audit also rejects exceptions for unknown graph IDs. Registry signature verification remains a separate control. -For pull requests, the required audit job produces the mcporter receipt, raw report, and trusted policy result. The managed-image build job verifies that evidence against candidate inputs with the pull request base SHA verifier before it builds. Protected runtime qualification runs the trusted audit action against candidate inputs before its offline build and supplies the same three files as BuildKit secrets. Standard trusted base- and managed-image publication carries the audit producer's named policy result with the same receipt and raw report. The action reuses matching, unexpired audit records or refreshes them through the configured registry. Offline consumers check the receipt and policy result transport hashes before retaining the trusted result. Evidence-backed final image reuse derives its policy provenance from that retained trusted result; the candidate exception registry supplies provenance only for builds that perform the audit directly. Other image builds without protected evidence run the reviewed audit directly and fail closed if completeness cannot be established. +The audit handoff has these boundaries: + +- Pull request audits produce a mcporter receipt, raw report, and trusted policy result. +- Managed-image builds verify those files against candidate inputs with the pull request base SHA verifier. +- Protected runtime qualification audits candidate inputs before its offline build and passes the three files to BuildKit as secrets. +- Trusted base-image and managed-image publication pass the producer's policy result, receipt, and raw report. +- The audit action reuses matching unexpired records or refreshes them through the configured registry. +- Offline consumers verify the receipt and policy-result transport hashes. +- Evidence-backed final image reuse derives policy provenance from the retained trusted result. +- Direct-audit builds derive provenance from the candidate exception registry. +- Builds without protected evidence run the reviewed audit directly and fail closed when the evidence is incomplete. ## WeChat plugin runtime graph From c0e5a73331c19e6254f49ccc6a554072e7ba5a7a Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:01:43 -0700 Subject: [PATCH 50/56] fix(ci): use current audit verifier flag Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .github/workflows/managed-images.yaml | 2 +- scripts/checks/build-protected-managed-images.sh | 2 +- .../images/protected-managed-image-build-script.test.ts | 8 ++++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 5e3c667e7ab..2fb91e5f258 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -523,7 +523,7 @@ jobs: --audit-config "$trusted_root/ci/reviewed-npm-audit.json" \ --registry https://registry.yarnpkg.com \ --threshold high \ - --legacy-npmjs true \ + --legacy-audit true \ --result "$policy" test -f "$receipt"; test -f "$raw"; test -s "$policy"; test ! -L "$policy" printf 'receipt=%s\nraw=%s\npolicy=%s\nreceipt_sha256=%s\npolicy_sha256=%s\n' "$receipt" "$raw" "$policy" "$(sha256sum "$receipt" | cut -d' ' -f1)" "$(sha256sum "$policy" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index a81ad8587f2..6956e38ed13 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -246,7 +246,7 @@ validate_audit_evidence() { --audit-config "$trusted_audit_config" \ --registry https://registry.yarnpkg.com \ --threshold high \ - --legacy-npmjs true \ + --legacy-audit true \ --result "$audit_policy_result" [[ -f "$audit_policy_result" && -s "$audit_policy_result" && ! -L "$audit_policy_result" ]] || { echo "ERROR: protected managed-image reviewed audit policy result is missing or unsafe" >&2 diff --git a/test/platform/images/protected-managed-image-build-script.test.ts b/test/platform/images/protected-managed-image-build-script.test.ts index d016b19751f..80fb69b41e3 100644 --- a/test/platform/images/protected-managed-image-build-script.test.ts +++ b/test/platform/images/protected-managed-image-build-script.test.ts @@ -184,7 +184,11 @@ function completeAuditEvidence(auditDirectory: string): void { function completeValidAuditEvidence(auditDirectory: string): void { const auditConfig = JSON.parse( readFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), - ) as { readonly npmVersion: string }; + ) as { + readonly npmArchiveSha256: string; + readonly npmIntegrity: string; + readonly npmVersion: string; + }; const exceptionFile = path.join(REPO_ROOT, "ci/npm-audit-exceptions.json"); const rawReportFile = path.join(auditDirectory, "audit.json"); mkdirSync(auditDirectory, { recursive: true }); @@ -201,11 +205,11 @@ function completeValidAuditEvidence(auditDirectory: string): void { emitAuditReceipt({ artifactDirectory: auditDirectory, graphId: "mcporter-runtime", - npmVersion: auditConfig.npmVersion, packageJsonFile: path.join(REPO_ROOT, "agents/openclaw/mcporter-runtime/package.json"), packageLockFile: path.join(REPO_ROOT, "agents/openclaw/mcporter-runtime/package-lock.json"), rawReportFile, registryOrigin: "https://registry.yarnpkg.com", + reviewedNpmIdentity: auditConfig, result: { acceptedAdvisories: [], blockingThreshold: "high", From fb2c91eb5f272369f74326f7e3b80814921fbb5b Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:37:05 -0700 Subject: [PATCH 51/56] fix(security): bind protected audit evidence Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .github/workflows/managed-images.yaml | 2 +- scripts/lib/verify-mcporter-audit.sh | 14 +++++++++++ ...nim-flow-managed-llama-cpp-profile.test.ts | 25 +++++++++++-------- .../setup-nim-flow-vllm-resume.test.ts | 8 ------ ...managed-image-publication-workflow.test.ts | 3 +++ test/security/mcporter-supply-chain.test.ts | 12 +++++++++ 6 files changed, 44 insertions(+), 20 deletions(-) diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 2fb91e5f258..248784fcdb7 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -1775,7 +1775,7 @@ jobs: receipt="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.receipt.json" raw="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.raw.json" policy="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.policy.json" - test -f "$receipt"; test -f "$raw"; test -f "$policy" + test -f "$receipt"; test -f "$raw"; test -s "$policy"; test ! -L "$policy" printf 'receipt=%s\nraw=%s\npolicy=%s\nreceipt_sha256=%s\npolicy_sha256=%s\n' "$receipt" "$raw" "$policy" "$(sha256sum "$receipt" | cut -d' ' -f1)" "$(sha256sum "$policy" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - name: Restore exact base image contract diff --git a/scripts/lib/verify-mcporter-audit.sh b/scripts/lib/verify-mcporter-audit.sh index ceb436f38ef..3ec0b941c12 100755 --- a/scripts/lib/verify-mcporter-audit.sh +++ b/scripts/lib/verify-mcporter-audit.sh @@ -48,5 +48,19 @@ printf '%s %s\n' "$policy_result_sha256" "$policy_result" | sha256sum --check - echo "ERROR: cached mcporter audit policy result hash does not match" >&2 exit 1 } +raw_report_sha256="$( + node -e ' + const value = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); + if (!/^[0-9a-f]{64}$/.test(value.rawResponseSha256 ?? "")) process.exit(1); + process.stdout.write(value.rawResponseSha256); + ' "$receipt" +)" || { + echo "ERROR: verified mcporter audit receipt does not declare a raw response SHA-256" >&2 + exit 1 +} +printf '%s %s\n' "$raw_report_sha256" "$raw_report" | sha256sum --check --status - || { + echo "ERROR: cached mcporter audit raw report does not match the verified receipt" >&2 + exit 1 +} [[ -z "$report_path" ]] || cp -- "$raw_report" "$report_path" [[ -z "$result_path" ]] || cp -- "$policy_result" "$result_path" diff --git a/src/lib/onboard/setup-nim-flow-managed-llama-cpp-profile.test.ts b/src/lib/onboard/setup-nim-flow-managed-llama-cpp-profile.test.ts index cabdd6c06b9..40cd3f0868a 100644 --- a/src/lib/onboard/setup-nim-flow-managed-llama-cpp-profile.test.ts +++ b/src/lib/onboard/setup-nim-flow-managed-llama-cpp-profile.test.ts @@ -529,21 +529,24 @@ describe("managed llama.cpp profile onboarding", () => { vi.stubEnv("NEMOCLAW_LLAMACPP_RECIPE", "llama-cpp.qwen3-6-35b-a3b.n1x-wsl.v1"); vi.stubEnv("DOCKER_CONTEXT", "remote-builder"); const installManagedLlamaCpp = vi.fn(); - const discoverManagedLlamaCppSelections = vi.fn( - (env, gpu, catalog, _collectionOptions, selectionOptions) => - discoverManagedLlamaCppSelectionsForGpu( - env, - gpu, - catalog, - n1xCollectionOptions(), - selectionOptions, - ), - ); const setupNim = createSetupNim( makeDeps({ isNonInteractive: () => true, getNonInteractiveProvider: () => "install-llama-cpp", - discoverManagedLlamaCppSelections, + discoverManagedLlamaCppSelections: ( + env, + detectedGpu, + catalog, + _collectionOptions, + selectionOptions, + ) => + discoverManagedLlamaCppSelectionsForGpu( + env, + detectedGpu, + catalog, + n1xCollectionOptions(), + selectionOptions, + ), installManagedLlamaCpp, }), ); diff --git a/src/lib/onboard/setup-nim-flow-vllm-resume.test.ts b/src/lib/onboard/setup-nim-flow-vllm-resume.test.ts index a709f5d0444..ea95abaa6e2 100644 --- a/src/lib/onboard/setup-nim-flow-vllm-resume.test.ts +++ b/src/lib/onboard/setup-nim-flow-vllm-resume.test.ts @@ -31,10 +31,6 @@ describe("createSetupNim vLLM resume", () => { const setupNim = createSetupNim( makeDeps({ getNonInteractiveProvider: () => "install-vllm", - discoverManagedLlamaCppSelections: () => ({ - choices: [], - resolution: { kind: "rejected", reason: "The vLLM test does not select llama.cpp." }, - }), selectFromNumberedMenu: () => unexpected("provider menu"), detectInferenceProviderHostState: () => makeHostState({ @@ -70,10 +66,6 @@ describe("createSetupNim vLLM resume", () => { const setupNim = createSetupNim( makeDeps({ getNonInteractiveProvider: () => "install-vllm", - discoverManagedLlamaCppSelections: () => ({ - choices: [], - resolution: { kind: "rejected", reason: "The vLLM test does not select llama.cpp." }, - }), selectFromNumberedMenu: () => unexpected("provider menu"), detectInferenceProviderHostState: () => makeHostState({ diff --git a/test/inference/managed/managed-image-publication-workflow.test.ts b/test/inference/managed/managed-image-publication-workflow.test.ts index c697490e3ea..f384a1c8ead 100644 --- a/test/inference/managed/managed-image-publication-workflow.test.ts +++ b/test/inference/managed/managed-image-publication-workflow.test.ts @@ -1149,6 +1149,7 @@ fi publisher = managedPublisher(workflow), action = readAction("publish-managed-image-digest"), source = JSON.stringify(workflow); + const auditEvidence = step(publisher, "Prepare same-run mcporter audit evidence"); expect(workflow.jobs?.["reviewed-npm-audit"]?.if).toBe("github.event_name != 'pull_request'"); expect(publisher.needs).toEqual(["publication-identity", "reviewed-npm-audit"]); expect( @@ -1166,6 +1167,8 @@ fi ].filter((marker) => !source.includes(marker)), ).toEqual([]); expect(source).not.toContain("NEMOCLAW_MCPORTER_AUDIT_RAW_REPORT_SHA256"); + expect(auditEvidence.run).toContain('test -s "$policy"'); + expect(auditEvidence.run).toContain('test ! -L "$policy"'); const actionSource = JSON.stringify(action); expect([ actionSource.includes('"secret-files":{"description"'), diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index 3c306853a84..7ceee8a3d1e 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -229,6 +229,18 @@ describe("mcporter image supply-chain controls", () => { expect(flattenedContents).toContain( "NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json bash /scripts/lib/verify-mcporter-audit.sh", ); + const receiptVerification = mcporterAuditHelper.indexOf( + '"$receipt_sha256" "$receipt" | sha256sum --check --status', + ); + const rawBinding = mcporterAuditHelper.indexOf("value.rawResponseSha256"); + const rawVerification = mcporterAuditHelper.indexOf( + '"$raw_report_sha256" "$raw_report" | sha256sum --check --status', + ); + const reportCopy = mcporterAuditHelper.indexOf('cp -- "$raw_report" "$report_path"'); + expect(receiptVerification).toBeGreaterThan(-1); + expect(rawBinding).toBeGreaterThan(receiptVerification); + expect(rawVerification).toBeGreaterThan(rawBinding); + expect(reportCopy).toBeGreaterThan(rawVerification); }); it("verifies the exact committed dependency graph signatures in trusted CI (#8925)", () => { From ffb23b118bce74109a3dca49386629964a65ff98 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:53:23 -0700 Subject: [PATCH 52/56] fix(security): preserve shell-only audit verification Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- scripts/lib/verify-mcporter-audit.sh | 9 +++------ test/security/mcporter-supply-chain.test.ts | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/scripts/lib/verify-mcporter-audit.sh b/scripts/lib/verify-mcporter-audit.sh index 3ec0b941c12..34f3a3c7534 100755 --- a/scripts/lib/verify-mcporter-audit.sh +++ b/scripts/lib/verify-mcporter-audit.sh @@ -49,12 +49,9 @@ printf '%s %s\n' "$policy_result_sha256" "$policy_result" | sha256sum --check - exit 1 } raw_report_sha256="$( - node -e ' - const value = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); - if (!/^[0-9a-f]{64}$/.test(value.rawResponseSha256 ?? "")) process.exit(1); - process.stdout.write(value.rawResponseSha256); - ' "$receipt" -)" || { + sed -n 's/.*"rawResponseSha256"[[:space:]]*:[[:space:]]*"\([0-9a-f]\{64\}\)".*/\1/p' "$receipt" | head -n 1 +)" +printf '%s' "$raw_report_sha256" | grep -qxE '[0-9a-f]{64}' || { echo "ERROR: verified mcporter audit receipt does not declare a raw response SHA-256" >&2 exit 1 } diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index 7ceee8a3d1e..5de35737f06 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -232,7 +232,7 @@ describe("mcporter image supply-chain controls", () => { const receiptVerification = mcporterAuditHelper.indexOf( '"$receipt_sha256" "$receipt" | sha256sum --check --status', ); - const rawBinding = mcporterAuditHelper.indexOf("value.rawResponseSha256"); + const rawBinding = mcporterAuditHelper.indexOf('"rawResponseSha256"'); const rawVerification = mcporterAuditHelper.indexOf( '"$raw_report_sha256" "$raw_report" | sha256sum --check --status', ); From ace89a72fe546cef62a81da19c56aa020383fe21 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:03:00 -0700 Subject: [PATCH 53/56] fix(security): parse cached audit receipts strictly Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- scripts/lib/verify-mcporter-audit.sh | 7 ++- .../reviewed-npm-audit-handoff.test.ts | 48 +++++++++++++++++++ test/security/mcporter-supply-chain.test.ts | 2 +- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/scripts/lib/verify-mcporter-audit.sh b/scripts/lib/verify-mcporter-audit.sh index 34f3a3c7534..11248a1b059 100755 --- a/scripts/lib/verify-mcporter-audit.sh +++ b/scripts/lib/verify-mcporter-audit.sh @@ -48,10 +48,9 @@ printf '%s %s\n' "$policy_result_sha256" "$policy_result" | sha256sum --check - echo "ERROR: cached mcporter audit policy result hash does not match" >&2 exit 1 } -raw_report_sha256="$( - sed -n 's/.*"rawResponseSha256"[[:space:]]*:[[:space:]]*"\([0-9a-f]\{64\}\)".*/\1/p' "$receipt" | head -n 1 -)" -printf '%s' "$raw_report_sha256" | grep -qxE '[0-9a-f]{64}' || { +raw_report_sha256="$(jq -er ' + .rawResponseSha256 | select(type == "string" and test("^[0-9a-f]{64}$")) +' "$receipt")" || { echo "ERROR: verified mcporter audit receipt does not declare a raw response SHA-256" >&2 exit 1 } diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 5564e9ad255..15580d97a91 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -593,6 +593,54 @@ describe("npm audit handoff", () => { expect(fs.existsSync(retainedResult)).toBe(false); expect(fs.existsSync(nodeLog)).toBe(false); + const forgedRawReport = path.join(root, "forged-raw-report.json"); + const forgedRawHelper = path.join(root, "verify-forged-mcporter-raw-report.sh"); + fs.writeFileSync(forgedRawReport, "{}\n"); + fs.writeFileSync( + forgedRawHelper, + helperSource.replaceAll(transportRawReport, forgedRawReport), + { mode: 0o755 }, + ); + const rejectedRawReport = runHelper( + correctReceiptSha256, + verifiedPolicyResultSha256, + forgedRawHelper, + ); + expect(rejectedRawReport.status).not.toBe(0); + expect(rejectedRawReport.stderr).toContain( + "raw report does not match the verified receipt", + ); + expect(fs.existsSync(retainedReport)).toBe(false); + expect(fs.existsSync(retainedResult)).toBe(false); + expect(fs.existsSync(nodeLog)).toBe(false); + + const malformedReceipt = path.join(root, "malformed-receipt.json"); + const malformedReceiptHelper = path.join(root, "verify-malformed-mcporter-receipt.sh"); + fs.writeFileSync( + malformedReceipt, + `not-json "rawResponseSha256":"${createHash("sha256").update(rawReport).digest("hex")}"\n`, + ); + fs.writeFileSync( + malformedReceiptHelper, + helperSource.replaceAll(receiptFile, malformedReceipt), + { mode: 0o755 }, + ); + const malformedReceiptSha256 = createHash("sha256") + .update(fs.readFileSync(malformedReceipt)) + .digest("hex"); + const rejectedMalformedReceipt = runHelper( + malformedReceiptSha256, + verifiedPolicyResultSha256, + malformedReceiptHelper, + ); + expect(rejectedMalformedReceipt.status).not.toBe(0); + expect(rejectedMalformedReceipt.stderr).toContain( + "receipt does not declare a raw response SHA-256", + ); + expect(fs.existsSync(retainedReport)).toBe(false); + expect(fs.existsSync(retainedResult)).toBe(false); + expect(fs.existsSync(nodeLog)).toBe(false); + const accepted = runHelper(); expect(accepted.status, accepted.stderr).toBe(0); expect(fs.readFileSync(transportRawReport, "utf8")).toBe(rawReport); diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index 5de35737f06..be30b8b2cf4 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -232,7 +232,7 @@ describe("mcporter image supply-chain controls", () => { const receiptVerification = mcporterAuditHelper.indexOf( '"$receipt_sha256" "$receipt" | sha256sum --check --status', ); - const rawBinding = mcporterAuditHelper.indexOf('"rawResponseSha256"'); + const rawBinding = mcporterAuditHelper.indexOf(".rawResponseSha256"); const rawVerification = mcporterAuditHelper.indexOf( '"$raw_report_sha256" "$raw_report" | sha256sum --check --status', ); From 31645fc00fce673ab03b76407b3249a193a4555b Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:54:26 -0700 Subject: [PATCH 54/56] style(test): apply audit handoff formatting Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/automation/releases/reviewed-npm-audit-handoff.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 15580d97a91..ba40ce08d4b 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -607,9 +607,7 @@ describe("npm audit handoff", () => { forgedRawHelper, ); expect(rejectedRawReport.status).not.toBe(0); - expect(rejectedRawReport.stderr).toContain( - "raw report does not match the verified receipt", - ); + expect(rejectedRawReport.stderr).toContain("raw report does not match the verified receipt"); expect(fs.existsSync(retainedReport)).toBe(false); expect(fs.existsSync(retainedResult)).toBe(false); expect(fs.existsSync(nodeLog)).toBe(false); From 38fa1ebe5fa396746a2c3b0c335b16f5c1658f23 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 11 Sep 2026 15:05:05 +0700 Subject: [PATCH 55/56] test: isolate vLLM profile selection from host probes --- src/lib/onboard/setup-nim-flow-serving-profile.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/lib/onboard/setup-nim-flow-serving-profile.test.ts b/src/lib/onboard/setup-nim-flow-serving-profile.test.ts index 9b473d00405..995a676d518 100644 --- a/src/lib/onboard/setup-nim-flow-serving-profile.test.ts +++ b/src/lib/onboard/setup-nim-flow-serving-profile.test.ts @@ -51,6 +51,13 @@ async function selectAgainstRunningVllm( isNonInteractive: () => true, getNonInteractiveProvider: () => "install-vllm", detectInferenceProviderHostState: () => runningVllmHostState(), + discoverManagedLlamaCppSelections: () => ({ + choices: [], + resolution: { + kind: "rejected", + reason: "the vLLM profile test does not select llama.cpp", + }, + }), handleVllmSelection, resolveRequestedServingProfileModel, selectVllmModelFromEnv, From ca72fcbfac849946db3552f2abff68b4627c6656 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 11 Sep 2026 16:57:05 +0700 Subject: [PATCH 56/56] test: retain bounded gateway diagnostics for activation failures --- .../managed-image-activation-e2e-helpers.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/e2e/live/managed-image-activation-e2e-helpers.ts b/test/e2e/live/managed-image-activation-e2e-helpers.ts index e7b777daa21..d4c3421615d 100644 --- a/test/e2e/live/managed-image-activation-e2e-helpers.ts +++ b/test/e2e/live/managed-image-activation-e2e-helpers.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { shellQuote } from "../../../src/lib/core/shell-quote.ts"; +import { resolveGatewayLogPathForPort } from "../../../src/lib/onboard/gateway/state-dir.ts"; import { type ManagedImageContractCatalog, type ManagedImageContractV1, @@ -343,6 +344,25 @@ async function collectOnboardFailureDockerDiagnostics( env: NodeJS.ProcessEnv, ): Promise { try { + await host.command( + "tail", + [ + "-c", + "65536", + resolveGatewayLogPathForPort({ + configured: env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR, + home: os.homedir(), + port: 8080, + }), + ], + { + artifactName: `managed-activation-onboard-failure-${agent}-gateway-log`, + captureLimitBytes: 65536, + env, + redactionValues: [API_KEY], + timeoutMs: 5_000, + }, + ); const inventory = await host.command( "docker", [