diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index c66ffa61b82..9776c02352b 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -94,6 +94,24 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false + - name: Make reviewed runtime bundle files group-writable + shell: bash + run: | + set -euo pipefail + runtime_bundle_files=( + tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle + tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/BUNDLED_PACKAGES.json + tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/THIRD_PARTY_LICENSES.txt + tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/mcp-tool-discovery.bundle + ) + for path in "${runtime_bundle_files[@]}"; do + if [[ ! -f "$path" || -L "$path" ]]; then + printf 'ERROR: reviewed runtime bundle input must be a regular non-symlink: %s\n' "$path" >&2 + exit 1 + fi + done + chmod 0664 "${runtime_bundle_files[@]}" + - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 @@ -190,6 +208,56 @@ jobs: provenance: false sbom: false + - name: Reject a symlinked runtime directory during image assembly + if: ${{ matrix.agent == 'langchain-deepagents-code' }} + shell: bash + env: + BASE_REFERENCE: ${{ steps.base.outputs.ref }} + DOCKERFILE: ${{ matrix.dockerfile }} + run: | + set -euo pipefail + probe_dockerfile="$RUNNER_TEMP/runtime-directory-symlink.Dockerfile" + probe_log="$RUNNER_TEMP/runtime-directory-symlink.log" + { + cat <<'PROBE_HEADER' + ARG BASE_IMAGE + FROM ${BASE_IMAGE} + USER root + COPY --chown=0:0 --chmod=0444 tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs + COPY --chown=0:0 --chmod=0444 tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/BUNDLED_PACKAGES.json /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime/BUNDLED_PACKAGES.json + COPY --chown=0:0 --chmod=0444 tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/THIRD_PARTY_LICENSES.txt /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime/THIRD_PARTY_LICENSES.txt + COPY --chown=0:0 --chmod=0444 tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/mcp-tool-discovery.bundle /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime/mcp-tool-discovery.mjs + RUN rm -rf /run/nemoclaw \ + && ln -s /tmp /run/nemoclaw + PROBE_HEADER + awk ' + /^RUN managed_runtime_assertion_failed\(\)/ { copying = 1 } + copying { print } + copying && /managed_runtime_assertion_failed runtime-directory-metadata-0:0:755 \/run\/nemoclaw$/ { exit } + ' "$DOCKERFILE" + } > "$probe_dockerfile" + + if [ "$(grep -c '^RUN managed_runtime_assertion_failed()' "$probe_dockerfile")" -ne 1 ]; then + echo "ERROR: could not extract one managed runtime assertion from $DOCKERFILE." >&2 + exit 1 + fi + if docker buildx build \ + --platform linux/amd64 \ + --build-arg "BASE_IMAGE=${BASE_REFERENCE}" \ + --progress plain \ + --file "$probe_dockerfile" \ + . > "$probe_log" 2>&1; then + echo "ERROR: image assembly accepted a symlinked /run/nemoclaw directory." >&2 + exit 1 + fi + if ! grep -Fq \ + 'ERROR: managed image assertion failed: runtime-directory-non-symlink path=/run/nemoclaw' \ + "$probe_log"; then + tail -80 "$probe_log" >&2 + echo "ERROR: image assembly failed without the runtime-directory-non-symlink diagnostic." >&2 + exit 1 + fi + - name: Validate exact PR managed image contract id: contract shell: bash diff --git a/Dockerfile b/Dockerfile index fcd6c3bfcab..1412200df7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1525,13 +1525,16 @@ RUN managed_runtime_assertion_failed() { \ && { chown root:root /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs 2>/dev/null || managed_runtime_assertion_failed owner-root-root /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs; } \ && { chmod 0444 /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs 2>/dev/null || managed_runtime_assertion_failed mode-0444 /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs; } \ && { test "$(stat -c '%u:%g:%a' /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs 2>/dev/null)" = '0:0:444' || managed_runtime_assertion_failed metadata-0:0:444 /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs; } \ - && test -f /usr/local/bin/nemoclaw-managed-bootstrap \ - && test ! -L /usr/local/bin/nemoclaw-managed-bootstrap \ - && test "$(stat -c '%u:%g:%a' /usr/local/bin/nemoclaw-managed-bootstrap)" = '0:0:755' \ - && test -f /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh \ - && test ! -L /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh \ - && test "$(stat -c '%u:%g:%a' /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh)" = '0:0:444' \ - && install -d -o root -g root -m 0755 /run/nemoclaw + && { test -f /usr/local/bin/nemoclaw-managed-bootstrap || managed_runtime_assertion_failed managed-bootstrap-regular-file /usr/local/bin/nemoclaw-managed-bootstrap; } \ + && { test ! -L /usr/local/bin/nemoclaw-managed-bootstrap || managed_runtime_assertion_failed managed-bootstrap-non-symlink /usr/local/bin/nemoclaw-managed-bootstrap; } \ + && { test "$(stat -c '%u:%g:%a' /usr/local/bin/nemoclaw-managed-bootstrap)" = '0:0:755' || managed_runtime_assertion_failed managed-bootstrap-metadata-0:0:755 /usr/local/bin/nemoclaw-managed-bootstrap; } \ + && { test -f /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh || managed_runtime_assertion_failed managed-bootstrap-trampoline-regular-file /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh; } \ + && { test ! -L /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh || managed_runtime_assertion_failed managed-bootstrap-trampoline-non-symlink /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh; } \ + && { test "$(stat -c '%u:%g:%a' /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh)" = '0:0:444' || managed_runtime_assertion_failed managed-bootstrap-trampoline-metadata-0:0:444 /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh; } \ + && { install -d -o root -g root -m 0755 /run/nemoclaw || managed_runtime_assertion_failed runtime-directory-create /run/nemoclaw; } \ + && { test -d /run/nemoclaw || managed_runtime_assertion_failed runtime-directory /run/nemoclaw; } \ + && { test ! -L /run/nemoclaw || managed_runtime_assertion_failed runtime-directory-non-symlink /run/nemoclaw; } \ + && { test "$(stat -c '%u:%g:%a' /run/nemoclaw 2>/dev/null)" = '0:0:755' || managed_runtime_assertion_failed runtime-directory-metadata-0:0:755 /run/nemoclaw; } # Copy startup script and shared sandbox initialisation library. RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 3e4cefe3e3b..dff64263527 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -387,13 +387,16 @@ RUN managed_runtime_assertion_failed() { \ && { chown root:root /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs 2>/dev/null || managed_runtime_assertion_failed owner-root-root /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs; } \ && { chmod 0444 /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs 2>/dev/null || managed_runtime_assertion_failed mode-0444 /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs; } \ && { test "$(stat -c '%u:%g:%a' /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs 2>/dev/null)" = '0:0:444' || managed_runtime_assertion_failed metadata-0:0:444 /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs; } \ - && test -f /usr/local/bin/nemoclaw-managed-bootstrap \ - && test ! -L /usr/local/bin/nemoclaw-managed-bootstrap \ - && test "$(stat -c '%u:%g:%a' /usr/local/bin/nemoclaw-managed-bootstrap)" = '0:0:755' \ - && test -f /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh \ - && test ! -L /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh \ - && test "$(stat -c '%u:%g:%a' /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh)" = '0:0:444' \ - && install -d -o root -g root -m 0755 /run/nemoclaw + && { test -f /usr/local/bin/nemoclaw-managed-bootstrap || managed_runtime_assertion_failed managed-bootstrap-regular-file /usr/local/bin/nemoclaw-managed-bootstrap; } \ + && { test ! -L /usr/local/bin/nemoclaw-managed-bootstrap || managed_runtime_assertion_failed managed-bootstrap-non-symlink /usr/local/bin/nemoclaw-managed-bootstrap; } \ + && { test "$(stat -c '%u:%g:%a' /usr/local/bin/nemoclaw-managed-bootstrap)" = '0:0:755' || managed_runtime_assertion_failed managed-bootstrap-metadata-0:0:755 /usr/local/bin/nemoclaw-managed-bootstrap; } \ + && { test -f /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh || managed_runtime_assertion_failed managed-bootstrap-trampoline-regular-file /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh; } \ + && { test ! -L /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh || managed_runtime_assertion_failed managed-bootstrap-trampoline-non-symlink /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh; } \ + && { test "$(stat -c '%u:%g:%a' /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh)" = '0:0:444' || managed_runtime_assertion_failed managed-bootstrap-trampoline-metadata-0:0:444 /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh; } \ + && { install -d -o root -g root -m 0755 /run/nemoclaw || managed_runtime_assertion_failed runtime-directory-create /run/nemoclaw; } \ + && { test -d /run/nemoclaw || managed_runtime_assertion_failed runtime-directory /run/nemoclaw; } \ + && { test ! -L /run/nemoclaw || managed_runtime_assertion_failed runtime-directory-non-symlink /run/nemoclaw; } \ + && { test "$(stat -c '%u:%g:%a' /run/nemoclaw 2>/dev/null)" = '0:0:755' || managed_runtime_assertion_failed runtime-directory-metadata-0:0:755 /run/nemoclaw; } # Ensure sandbox user can read blueprint files copied as root RUN chmod -R a+rX /opt/nemoclaw-blueprint/ diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index b6c97012657..17b5cf82a10 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -137,7 +137,10 @@ RUN managed_runtime_assertion_failed() { \ && { chown root:root /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs 2>/dev/null || managed_runtime_assertion_failed owner-root-root /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs; } \ && { chmod 0444 /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs 2>/dev/null || managed_runtime_assertion_failed mode-0444 /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs; } \ && { test "$(stat -c '%u:%g:%a' /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs 2>/dev/null)" = '0:0:444' || managed_runtime_assertion_failed metadata-0:0:444 /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs; } \ - && install -d -o root -g root -m 0755 /run/nemoclaw + && { install -d -o root -g root -m 0755 /run/nemoclaw || managed_runtime_assertion_failed runtime-directory-create /run/nemoclaw; } \ + && { test -d /run/nemoclaw || managed_runtime_assertion_failed runtime-directory /run/nemoclaw; } \ + && { test ! -L /run/nemoclaw || managed_runtime_assertion_failed runtime-directory-non-symlink /run/nemoclaw; } \ + && { test "$(stat -c '%u:%g:%a' /run/nemoclaw 2>/dev/null)" = '0:0:755' || managed_runtime_assertion_failed runtime-directory-metadata-0:0:755 /run/nemoclaw; } COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index b9339837762..8a6cb150476 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -286,11 +286,21 @@ "test": "keeps fork-safe labeling inside the trusted metadata boundary", "category": "security" }, + { + "file": "test/managed-image-publication-workflow.test.ts", + "test": "builds and exercises every shipped agent from an exact PR image before merge (#7744)", + "category": "security" + }, { "file": "test/messaging-image-env-contract.test.ts", "test": "%s keeps the full plan in build processes but not final runtime environments (#5896)", "category": "security" }, + { + "file": "test/mcp-tool-discovery-image-contract.test.ts", + "test": "builds managed images when reviewed runtime bundle files are group-writable (#8665)", + "category": "security" + }, { "file": "test/mcp-tool-discovery-image-contract.test.ts", "test": "pins reviewed packages and audits their lock outside image builds (#8253)", diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index a94567dd52d..125ef042679 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -393,7 +393,7 @@ describe("Hermes final image layout", () => { ); const managedRuntimeDirectory = indexOfRequired( finalStage, - "&& install -d -o root -g root -m 0755 /run/nemoclaw", + "install -d -o root -g root -m 0755 /run/nemoclaw \\\n || managed_runtime_assertion_failed runtime-directory-create /run/nemoclaw; \\\n test -d /run/nemoclaw \\\n || managed_runtime_assertion_failed runtime-directory /run/nemoclaw; \\\n test ! -L /run/nemoclaw \\\n || managed_runtime_assertion_failed runtime-directory-non-symlink /run/nemoclaw; \\\n test \"$(stat -c '%u:%g:%a' /run/nemoclaw 2>/dev/null)\" = '0:0:755' \\\n || managed_runtime_assertion_failed runtime-directory-metadata-0:0:755 /run/nemoclaw", ); const runtimeModeReplay = indexOfRequired( finalStage, @@ -425,7 +425,7 @@ describe("Hermes final image layout", () => { expect(managedRuntimeDirectory).toBeLessThan(runtimeModeReplay); expect(finalStage).toContain("/usr/local/bin/nemoclaw-managed-bootstrap"); expect(dockerfile).toContain( - "COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle /out/managed-startup-image-runtime.cjs", + "COPY --chown=0:0 --chmod=0444 tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle /out/managed-startup-image-runtime.cjs", ); expect(dockerfile).not.toContain( "COPY src/lib/onboard/managed-bootstrap/ ./src/lib/onboard/managed-bootstrap/", diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index c3e432f0f92..50aecc83042 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -162,7 +162,8 @@ describe("LangChain Deep Agents Code image contracts", () => { "# explicitly before installing the root-owned managed-startup handoff.", "USER root", ].join("\n"); - const managedRuntimeDirectory = "&& install -d -o root -g root -m 0755 /run/nemoclaw"; + const managedRuntimeDirectory = + "install -d -o root -g root -m 0755 /run/nemoclaw \\\n || managed_runtime_assertion_failed runtime-directory-create /run/nemoclaw; \\\n test -d /run/nemoclaw \\\n || managed_runtime_assertion_failed runtime-directory /run/nemoclaw; \\\n test ! -L /run/nemoclaw \\\n || managed_runtime_assertion_failed runtime-directory-non-symlink /run/nemoclaw; \\\n test \"$(stat -c '%u:%g:%a' /run/nemoclaw 2>/dev/null)\" = '0:0:755' \\\n || managed_runtime_assertion_failed runtime-directory-metadata-0:0:755 /run/nemoclaw"; const runtimeModeReplay = "&& chmod 444 /opt/nemoclaw-deepagents-code/generate-config.ts"; expect(dockerfile).toContain("ARG BASE_IMAGE\n"); @@ -201,7 +202,7 @@ describe("LangChain Deep Agents Code image contracts", () => { dockerfile.indexOf(runtimeModeReplay), ); expect(dockerfile).toContain( - "COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle /out/managed-startup-image-runtime.cjs", + "COPY --chown=0:0 --chmod=0444 tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle /out/managed-startup-image-runtime.cjs", ); expect(dockerfile).not.toContain( "COPY src/lib/onboard/managed-bootstrap/ ./src/lib/onboard/managed-bootstrap/", diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 4778e43db75..3af02a44f95 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -435,8 +435,11 @@ describe("complete managed-image publication workflow", () => { } }); + // source-shape-contract: security -- PR image builds must reject a symlinked runtime path before final image contract validation it("builds and exercises every shipped agent from an exact PR image before merge (#7744)", () => { - const workflow = readWorkflow("managed-images.yaml"); + const workflow = YAML.parse( + fs.readFileSync(path.join(repoRoot, ".github/workflows/managed-images.yaml"), "utf8"), + ) as Workflow; const prBuilder = managedPrBuilder(workflow); const matrix = prBuilder.strategy?.matrix?.include ?? []; const steps = prBuilder.steps ?? []; @@ -466,11 +469,28 @@ describe("complete managed-image publication workflow", () => { expect(resolveBase).toContain('reference="${BASE_REPOSITORY}@${digest}"'); expect(resolveBase).toContain('actual="sha256:$(sha256sum "$exact_raw"'); - expect(step(prBuilder, "Build PR managed image locally").with).toMatchObject({ + const build = step(prBuilder, "Build PR managed image locally"); + expect(build.with).toMatchObject({ platforms: "linux/amd64", load: true, push: false, }); + const runtimeDirectoryProbe = step( + prBuilder, + "Reject a symlinked runtime directory during image assembly", + ); + expect(runtimeDirectoryProbe.if).toBe("${{ matrix.agent == 'langchain-deepagents-code' }}"); + expect(runtimeDirectoryProbe.run).toContain("docker buildx build"); + expect(runtimeDirectoryProbe.run).toContain( + "managed_runtime_assertion_failed runtime-directory-metadata-0:0:755", + ); + expect(runtimeDirectoryProbe.run).toContain( + "ERROR: managed image assertion failed: runtime-directory-non-symlink path=/run/nemoclaw", + ); + expect(steps.indexOf(build)).toBeLessThan(steps.indexOf(runtimeDirectoryProbe)); + expect(steps.indexOf(runtimeDirectoryProbe)).toBeLessThan( + steps.indexOf(step(prBuilder, "Validate exact PR managed image contract")), + ); expect(step(prBuilder, "Exercise managed startup root stdin and hold").run).toContain( "run-managed-image-direct-e2e.ts", ); diff --git a/test/mcp-tool-discovery-image-contract.test.ts b/test/mcp-tool-discovery-image-contract.test.ts index 3b9030eab95..2bd88a79a18 100644 --- a/test/mcp-tool-discovery-image-contract.test.ts +++ b/test/mcp-tool-discovery-image-contract.test.ts @@ -9,14 +9,58 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { stripVTControlCharacters } from "node:util"; import { describe, expect, it } from "vitest"; +import YAML from "yaml"; const repoRoot = path.join(import.meta.dirname, ".."); +const managedImagesWorkflowPath = path.join(repoRoot, ".github/workflows/managed-images.yaml"); const runtimeRoot = "/usr/local/lib/nemoclaw/mcp-tool-discovery-runtime"; const dockerfiles = [ "Dockerfile", "agents/hermes/Dockerfile", "agents/langchain-deepagents-code/Dockerfile", ] as const; +const reviewedRuntimeBundleFiles = [ + "tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle", + "tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/BUNDLED_PACKAGES.json", + "tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/THIRD_PARTY_LICENSES.txt", + "tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/mcp-tool-discovery.bundle", +] as const; + +type ManagedImagesWorkflow = { + jobs?: Record }>; +}; + +const invalidReviewedBundlePreparers: ReadonlyArray< + (fixture: string, invalidPath: string) => void +> = [ + () => undefined, + (_fixture, invalidPath) => fs.mkdirSync(invalidPath), + (fixture, invalidPath) => { + const targetPath = path.join(fixture, "symlink-target.bundle"); + fs.writeFileSync(targetPath, "unreviewed target\n", { mode: 0o644 }); + fs.symlinkSync(targetPath, invalidPath); + }, +]; + +function required(value: T | undefined, message: string): T { + return ( + value ?? + (() => { + throw new Error(message); + })() + ); +} + +function createReviewedRuntimeBundleFixture(): string { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-runtime-bundle-")); + for (const bundleFile of reviewedRuntimeBundleFiles) { + const fixturePath = path.join(fixture, bundleFile); + fs.mkdirSync(path.dirname(fixturePath), { recursive: true }); + fs.writeFileSync(fixturePath, "reviewed runtime fixture\n"); + fs.chmodSync(fixturePath, 0o644); + } + return fixture; +} function createCacheSeedFixture(): { cache: string; @@ -103,6 +147,84 @@ function createCacheSeedFixture(): { } describe("MCP tool discovery image contract", () => { + // source-shape-contract: security -- The PR image build must reproduce group-writable inputs before validating the immutable bundle boundary + it("builds managed images when reviewed runtime bundle files are group-writable (#8665)", () => { + const workflow = YAML.parse( + fs.readFileSync(managedImagesWorkflowPath, "utf8"), + ) as ManagedImagesWorkflow; + const steps = workflow.jobs?.["pr-build-and-entrypoint"]?.steps ?? []; + const metadataSteps = steps.filter( + (step) => step.name === "Make reviewed runtime bundle files group-writable", + ); + const metadataStep = required( + metadataSteps[0], + "managed image workflow is missing the reviewed bundle step", + ); + const buildStep = required( + steps.find((step) => step.name === "Build PR managed image locally"), + "managed image workflow is missing the build step", + ); + const expectedRun = [ + "set -euo pipefail", + "runtime_bundle_files=(", + ...reviewedRuntimeBundleFiles.map((bundleFile) => ` ${bundleFile}`), + ")", + 'for path in "${runtime_bundle_files[@]}"; do', + ' if [[ ! -f "$path" || -L "$path" ]]; then', + " printf 'ERROR: reviewed runtime bundle input must be a regular non-symlink: %s\\n' \"$path\" >&2", + " exit 1", + " fi", + "done", + 'chmod 0664 "${runtime_bundle_files[@]}"', + "", + ].join("\n"); + + expect(metadataSteps).toHaveLength(1); + const metadataStepRun = required(metadataStep.run, "reviewed bundle step is missing run"); + expect(metadataStepRun).toBe(expectedRun); + expect(steps.indexOf(metadataStep)).toBeLessThan(steps.indexOf(buildStep)); + + const validFixture = createReviewedRuntimeBundleFixture(); + try { + const valid = spawnSync("bash", ["-c", metadataStepRun], { + cwd: validFixture, + encoding: "utf8", + }); + expect(valid.status, valid.stderr).toBe(0); + expect(valid.stderr).toBe(""); + for (const bundleFile of reviewedRuntimeBundleFiles) { + expect(fs.statSync(path.join(validFixture, bundleFile)).mode & 0o777).toBe(0o664); + } + } finally { + fs.rmSync(validFixture, { force: true, recursive: true }); + } + + for (const prepareInvalidBundle of invalidReviewedBundlePreparers) { + const invalidFixture = createReviewedRuntimeBundleFixture(); + const invalidBundleFile = reviewedRuntimeBundleFiles[3]; + const invalidPath = path.join(invalidFixture, invalidBundleFile); + fs.unlinkSync(invalidPath); + prepareInvalidBundle(invalidFixture, invalidPath); + + try { + const invalid = spawnSync("bash", ["-c", metadataStepRun], { + cwd: invalidFixture, + encoding: "utf8", + }); + expect(invalid.status).toBe(1); + expect(invalid.stdout).toBe(""); + expect(invalid.stderr).toBe( + `ERROR: reviewed runtime bundle input must be a regular non-symlink: ${invalidBundleFile}\n`, + ); + expect( + fs.statSync(path.join(invalidFixture, reviewedRuntimeBundleFiles[0])).mode & 0o777, + ).toBe(0o644); + } finally { + fs.rmSync(invalidFixture, { force: true, recursive: true }); + } + } + }); + // source-shape-contract: security -- Exact package pins and the CI audit mapping protect the shipped runtime graph it("pins reviewed packages and audits their lock outside image builds (#8253)", () => { const packageRoot = path.join(repoRoot, "tools", "mcp-tool-discovery-runtime"); @@ -199,7 +321,7 @@ describe("MCP tool discovery image contract", () => { "COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/ /usr/local/lib/nemoclaw-build-tools/npm-cache-seed/", ); expect(openClawDockerfile).toContain( - "COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/mcp-tool-discovery.bundle /opt/mcp-tool-discovery-runtime/dist/mcp-tool-discovery.mjs", + "COPY --chown=0:0 --chmod=0444 tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/mcp-tool-discovery.bundle /opt/mcp-tool-discovery-runtime/dist/mcp-tool-discovery.mjs", ); expect(openClawDockerfile).not.toContain("mcp-runtime-npm-cache-seed/"); expect(openClawDockerfile).not.toContain("install-reviewed-runtime.sh"); @@ -434,10 +556,13 @@ describe("MCP tool discovery image contract", () => { const dockerfile = fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); expect(dockerfile).toContain( - "COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/mcp-tool-discovery.bundle /opt/mcp-tool-discovery-runtime/dist/mcp-tool-discovery.mjs", + "COPY --chown=0:0 --chmod=0444 tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/mcp-tool-discovery.bundle /opt/mcp-tool-discovery-runtime/dist/mcp-tool-discovery.mjs", + ); + expect(dockerfile).toContain( + "COPY --chown=0:0 --chmod=0444 tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle /out/managed-startup-image-runtime.cjs", ); expect(dockerfile).toContain( - "COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle /out/managed-startup-image-runtime.cjs", + "COPY --chown=0:0 --chmod=0444 tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/BUNDLED_PACKAGES.json tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/THIRD_PARTY_LICENSES.txt /opt/mcp-tool-discovery-runtime/dist/", ); expect(dockerfile).toContain( `COPY --from=mcp-tool-discovery-runtime /opt/mcp-tool-discovery-runtime/dist/ ${runtimeRoot}/`, diff --git a/test/openclaw-final-image-layout.test.ts b/test/openclaw-final-image-layout.test.ts index 16230300267..ef49009eb45 100644 --- a/test/openclaw-final-image-layout.test.ts +++ b/test/openclaw-final-image-layout.test.ts @@ -222,7 +222,7 @@ describe("OpenClaw final image layout", () => { ); const managedRuntimeDirectory = indexOfRequired( finalStage, - "&& install -d -o root -g root -m 0755 /run/nemoclaw", + "install -d -o root -g root -m 0755 /run/nemoclaw \\\n || managed_runtime_assertion_failed runtime-directory-create /run/nemoclaw; \\\n test -d /run/nemoclaw \\\n || managed_runtime_assertion_failed runtime-directory /run/nemoclaw; \\\n test ! -L /run/nemoclaw \\\n || managed_runtime_assertion_failed runtime-directory-non-symlink /run/nemoclaw; \\\n test \"$(stat -c '%u:%g:%a' /run/nemoclaw 2>/dev/null)\" = '0:0:755' \\\n || managed_runtime_assertion_failed runtime-directory-metadata-0:0:755 /run/nemoclaw", ); const runtimeChmod = indexOfRequired(finalStage, "RUN chmod 755 /usr/local/bin/nemoclaw-start"); @@ -247,7 +247,7 @@ describe("OpenClaw final image layout", () => { expect(managedRuntimeDirectory).toBeLessThan(runtimeChmod); expect(finalStage).toContain("/usr/local/bin/nemoclaw-managed-bootstrap"); expect(dockerfile).toContain( - "COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle /out/managed-startup-image-runtime.cjs", + "COPY --chown=0:0 --chmod=0444 tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle /out/managed-startup-image-runtime.cjs", ); expect(dockerfile).not.toContain( "COPY src/lib/onboard/managed-bootstrap/ ./src/lib/onboard/managed-bootstrap/", diff --git a/test/support/managed-bootstrap-image-contract.ts b/test/support/managed-bootstrap-image-contract.ts index cb9507658aa..563c68ffb8c 100644 --- a/test/support/managed-bootstrap-image-contract.ts +++ b/test/support/managed-bootstrap-image-contract.ts @@ -77,6 +77,10 @@ function expectManagedRuntimeDiagnostic(dockerfile: string): void { `chown root:root ${MANAGED_STARTUP_RUNTIME_PATH} 2>/dev/null || managed_runtime_assertion_failed owner-root-root ${MANAGED_STARTUP_RUNTIME_PATH}`, `chmod 0444 ${MANAGED_STARTUP_RUNTIME_PATH} 2>/dev/null || managed_runtime_assertion_failed mode-0444 ${MANAGED_STARTUP_RUNTIME_PATH}`, `test \"$(stat -c '%u:%g:%a' ${MANAGED_STARTUP_RUNTIME_PATH} 2>/dev/null)\" = '0:0:444' || managed_runtime_assertion_failed metadata-0:0:444 ${MANAGED_STARTUP_RUNTIME_PATH}`, + "install -d -o root -g root -m 0755 /run/nemoclaw || managed_runtime_assertion_failed runtime-directory-create /run/nemoclaw", + "test -d /run/nemoclaw || managed_runtime_assertion_failed runtime-directory /run/nemoclaw", + "test ! -L /run/nemoclaw || managed_runtime_assertion_failed runtime-directory-non-symlink /run/nemoclaw", + "test \"$(stat -c '%u:%g:%a' /run/nemoclaw 2>/dev/null)\" = '0:0:755' || managed_runtime_assertion_failed runtime-directory-metadata-0:0:755 /run/nemoclaw", ]) { expect(logicalInstruction.split(assertion)).toHaveLength(2); } @@ -85,8 +89,12 @@ function expectManagedRuntimeDiagnostic(dockerfile: string): void { const missingPath = path.join(tmp, "missing-runtime.cjs"); const targetPath = path.join(tmp, "runtime-target.cjs"); const linkPath = path.join(tmp, "runtime-link.cjs"); + const runtimeDirectoryTarget = path.join(tmp, "runtime-directory-target"); + const runtimeDirectoryLink = path.join(tmp, "runtime-directory-link"); fs.writeFileSync(targetPath, "fixture\n", { mode: 0o444 }); fs.symlinkSync(targetPath, linkPath); + fs.mkdirSync(runtimeDirectoryTarget); + fs.symlinkSync(runtimeDirectoryTarget, runtimeDirectoryLink); const runDiscoveryChecks = ({ discoveryOutput = DISCOVERY_EXPECTED_CONTRACT, discoveryStatus = 0, @@ -311,6 +319,17 @@ function expectManagedRuntimeDiagnostic(dockerfile: string): void { expect(symlink.stderr).toBe( `ERROR: managed image assertion failed: non-symlink path=${linkPath} uid=0 gid=0 type=symbolic link mode=777 symlink=yes\n`, ); + + const runtimeDirectorySymlink = runDiagnostic( + runtimeDirectoryLink, + "runtime-directory-non-symlink", + "uid=0 gid=0 type=symbolic link mode=777", + ); + expect(runtimeDirectorySymlink.status).toBe(1); + expect(runtimeDirectorySymlink.stdout).toBe(""); + expect(runtimeDirectorySymlink.stderr).toBe( + `ERROR: managed image assertion failed: runtime-directory-non-symlink path=${runtimeDirectoryLink} uid=0 gid=0 type=symbolic link mode=777 symlink=yes\n`, + ); } finally { fs.rmSync(tmp, { force: true, recursive: true }); }