diff --git a/.github/actions/docker-auth-setup/action.yaml b/.github/actions/docker-auth-setup/action.yaml new file mode 100644 index 0000000000..fa20df6631 --- /dev/null +++ b/.github/actions/docker-auth-setup/action.yaml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: docker-auth-setup +description: Authenticate to Docker Hub from an isolated per-job Docker config, fail closed. + +inputs: + auth-required: + description: Whether trusted Docker Hub credentials are present for this run. + required: true + username: + description: Docker Hub username; only populated for trusted runs. + required: false + default: "" + token: + description: Docker Hub token; only populated for trusted runs. + required: false + default: "" + +runs: + using: composite + steps: + - name: Authenticate to Docker Hub + shell: bash + env: + DOCKERHUB_AUTH_REQUIRED: ${{ inputs.auth-required }} + DOCKERHUB_USERNAME: ${{ inputs.username }} + DOCKERHUB_TOKEN: ${{ inputs.token }} + run: bash "${{ github.action_path }}/../../scripts/docker-auth-setup.sh" diff --git a/.github/actions/host-dependency-setup/action.yaml b/.github/actions/host-dependency-setup/action.yaml new file mode 100644 index 0000000000..30ee422801 --- /dev/null +++ b/.github/actions/host-dependency-setup/action.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: host-dependency-setup +description: Install reviewed apt host dependencies with bounded retries from a trusted pinned action. + +inputs: + packages: + description: Space-separated apt packages from the reviewed allowlist (expect, iptables). + required: true + +runs: + using: composite + steps: + - name: Install host dependencies + shell: bash + env: + HOST_DEPENDENCY_PACKAGES: ${{ inputs.packages }} + run: bash "${{ github.action_path }}/../../scripts/host-dependency-setup.sh" diff --git a/.github/scripts/docker-auth-setup.sh b/.github/scripts/docker-auth-setup.sh new file mode 100755 index 0000000000..c0235a2253 --- /dev/null +++ b/.github/scripts/docker-auth-setup.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +if (($# != 0)); then + echo "::error::Docker auth setup does not accept arguments." >&2 + exit 1 +fi + +docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX")" +chmod 700 "${docker_config}" +export DOCKER_CONFIG="${docker_config}" +printf 'DOCKER_CONFIG=%s\n' "${DOCKER_CONFIG}" >>"${GITHUB_ENV}" + +if [[ "${DOCKERHUB_AUTH_REQUIRED}" != "1" ]]; then + echo "::notice::Docker Hub credentials are withheld for this ref; continuing with anonymous pulls." + exit 0 +fi +if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then + echo "::error::Docker Hub credentials are required for trusted E2E runs." + exit 1 +fi + +auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted" +: >"${auth_marker}" +chmod 600 "${auth_marker}" +login_succeeded=0 +for attempt in 1 2 3; do + if printf '%s' "${DOCKERHUB_TOKEN}" | timeout 30s docker login docker.io --username "${DOCKERHUB_USERNAME}" --password-stdin; then + login_succeeded=1 + break + fi + if [[ "${attempt}" -lt 3 ]]; then + echo "::warning::Docker Hub login attempt ${attempt} failed; retrying." + sleep 5 + fi +done +if [[ "${login_succeeded}" -ne 1 ]]; then + echo "::error::Docker Hub login failed after 3 attempts." + exit 1 +fi diff --git a/.github/scripts/host-dependency-setup.sh b/.github/scripts/host-dependency-setup.sh new file mode 100755 index 0000000000..2ee2c78c06 --- /dev/null +++ b/.github/scripts/host-dependency-setup.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +if (($# != 0)); then + echo "::error::Host dependency setup does not accept arguments." >&2 + exit 1 +fi + +if [[ -z "${HOST_DEPENDENCY_PACKAGES:-}" ]]; then + echo "::error::Host dependency setup requires at least one package." >&2 + exit 1 +fi + +if [[ "${HOST_DEPENDENCY_PACKAGES}" == *[$'\t\r\n']* ]]; then + echo "::error::Host dependency packages must be space-separated on one line." >&2 + exit 1 +fi + +read -r -a requested_packages <<<"${HOST_DEPENDENCY_PACKAGES}" +if ((${#requested_packages[@]} == 0)); then + echo "::error::Host dependency setup requires at least one package." >&2 + exit 1 +fi +allowlist=" expect iptables " +for package in "${requested_packages[@]}"; do + if [[ "${allowlist}" != *" ${package} "* ]]; then + echo "::error::Host dependency package '${package}' is outside the reviewed allowlist." >&2 + exit 1 + fi +done + +for attempt in 1 2 3; do + if sudo apt-get update; then + break + fi + if [[ "${attempt}" -eq 3 ]]; then + echo "::error::apt-get update failed after 3 attempts." >&2 + exit 1 + fi + echo "::warning::apt-get update attempt ${attempt} failed; retrying." >&2 + sleep $((attempt * 5)) +done +sudo apt-get install -y --no-install-recommends "${requested_packages[@]}" diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 36a0e27668..76cff03890 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -209,45 +209,11 @@ jobs: # explicit because strict YAML decoders reject 100 or more aliases here. - &dockerhub-auth name: Authenticate to Docker Hub - env: - DOCKERHUB_AUTH_REQUIRED: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '1' || '0' }} - DOCKERHUB_USERNAME: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && secrets.DOCKERHUB_USERNAME || '' }} - DOCKERHUB_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && secrets.DOCKERHUB_TOKEN || '' }} - shell: bash - run: | - set -euo pipefail - docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX")" - chmod 700 "${docker_config}" - export DOCKER_CONFIG="${docker_config}" - printf 'DOCKER_CONFIG=%s\n' "${DOCKER_CONFIG}" >> "${GITHUB_ENV}" - - if [[ "${DOCKERHUB_AUTH_REQUIRED}" != "1" ]]; then - echo "::notice::Docker Hub credentials are withheld for this ref; continuing with anonymous pulls." - exit 0 - fi - if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then - echo "::error::Docker Hub credentials are required for trusted E2E runs." - exit 1 - fi - - auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted" - : > "${auth_marker}" - chmod 600 "${auth_marker}" - login_succeeded=0 - for attempt in 1 2 3; do - if printf '%s' "${DOCKERHUB_TOKEN}" | timeout 30s docker login docker.io --username "${DOCKERHUB_USERNAME}" --password-stdin; then - login_succeeded=1 - break - fi - if [[ "${attempt}" -lt 3 ]]; then - echo "::warning::Docker Hub login attempt ${attempt} failed; retrying." - sleep 5 - fi - done - if [[ "${login_succeeded}" -ne 1 ]]; then - echo "::error::Docker Hub login failed after 3 attempts." - exit 1 - fi + uses: NVIDIA/NemoClaw/.github/actions/docker-auth-setup@78091da47e290f49b8fe3f3e70b72362a0853928 + with: + auth-required: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && inputs.checkout_sha == '' && '1' || '0' }} + username: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && inputs.checkout_sha == '' && secrets.DOCKERHUB_USERNAME || '' }} + token: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && inputs.checkout_sha == '' && secrets.DOCKERHUB_TOKEN || '' }} - name: Configure live E2E trace directory env: @@ -259,32 +225,21 @@ jobs: # invalidState: the selected PR-modifiable TUI check needs a PTY driver, # but the fixed GitHub-hosted runner image does not provide expect. - # sourceBoundary: this trusted workflow owns host setup; the PR-controlled - # check only verifies and consumes expect without privilege. + # sourceBoundary: privileged host setup runs from the first-party + # host-dependency-setup action pinned to an immutable full SHA, never the + # PR-controlled target ref; the check only consumes expect without privilege. # whyNotSourceFix: GitHub-hosted jobs cannot use a repository-owned host # image, and caching privileged dpkg state between clean runners is not # supported. # regressionTest: the workflow-boundary suite pins this target, condition, - # ordering, retry contract, and exact one-package apt allowlist. + # ordering, action provenance, and package mapping. # removalCondition: remove the install when the hosted runner supplies # expect or the acceptance check no longer requires a PTY. - name: Install Deep Agents Code TUI host dependencies if: ${{ matrix.id == 'ubuntu-repo-cloud-langchain-deepagents-code' }} - shell: bash - run: | - set -euo pipefail - for attempt in 1 2 3; do - if sudo apt-get update; then - break - fi - if [ "$attempt" -eq 3 ]; then - echo "::error::apt-get update failed after 3 attempts." >&2 - exit 1 - fi - echo "::warning::apt-get update attempt ${attempt} failed; retrying." >&2 - sleep $((attempt * 5)) - done - sudo apt-get install -y --no-install-recommends expect + uses: NVIDIA/NemoClaw/.github/actions/host-dependency-setup@4def1501b34ce586f83b91af50a66b5d22b31d75 + with: + packages: expect # Configure NEMOCLAW_TRACE_DIR before workspace prep so every child # command writes raw traces under runner temp, never under upload roots. @@ -1369,28 +1324,16 @@ jobs: - *dockerhub-auth - # This free-standing job checks out and executes the PR target ref. It - # keeps privileged host dependency setup inline in trusted workflow YAML - # rather than loading a repo-local action from the target ref after - # checkout. Only expect and iptables are allowed here: the TUI driver and - # egress-isolation assertion require them. Workflow contract tests pin - # the retry behavior and exact package list. + # This free-standing job checks out and executes the PR target ref. Its + # privileged host dependency setup loads the first-party + # host-dependency-setup action pinned to an immutable full SHA, so the + # target ref never runs sudo with its own code. Only expect and iptables + # are allowed here: the TUI driver and egress-isolation assertion require + # them. Workflow contract tests pin the action provenance and package list. - name: "Install issue #4434 host dependencies" - shell: bash - run: | - set -euo pipefail - for attempt in 1 2 3; do - if sudo apt-get update; then - break - fi - if [ "$attempt" -eq 3 ]; then - echo "::error::apt-get update failed after 3 attempts." >&2 - exit 1 - fi - echo "::warning::apt-get update attempt ${attempt} failed; retrying." >&2 - sleep $((attempt * 5)) - done - sudo apt-get install -y --no-install-recommends expect iptables + uses: NVIDIA/NemoClaw/.github/actions/host-dependency-setup@4def1501b34ce586f83b91af50a66b5d22b31d75 + with: + packages: expect iptables - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 @@ -2052,26 +1995,14 @@ jobs: - *dockerhub-auth # Expect is a reviewed host-tool consumer for the interactive policy-add - # test. Keep this privileged setup inline in trusted workflow YAML. This - # job executes a selected target ref, so it must not load a repo-local - # action from that ref with sudo privileges. Only expect is allowed here; - # iptables is scoped to the issue #4434 egress-isolation job. + # test. This job executes a selected target ref, so privileged setup runs + # from the first-party host-dependency-setup action pinned to an immutable + # full SHA, never that ref. Only expect is allowed here; iptables is scoped + # to the issue #4434 egress-isolation job. - name: Install network-policy host dependencies - shell: bash - run: | - set -euo pipefail - for attempt in 1 2 3; do - if sudo apt-get update; then - break - fi - if [ "$attempt" -eq 3 ]; then - echo "::error::apt-get update failed after 3 attempts." >&2 - exit 1 - fi - echo "::warning::apt-get update attempt ${attempt} failed; retrying." >&2 - sleep $((attempt * 5)) - done - sudo apt-get install -y --no-install-recommends expect + uses: NVIDIA/NemoClaw/.github/actions/host-dependency-setup@4def1501b34ce586f83b91af50a66b5d22b31d75 + with: + packages: expect - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 @@ -3119,31 +3050,20 @@ jobs: # invalidState: the cloud-onboard DCode TUI check requires a PTY driver, # but the fixed GitHub-hosted runner image does not provide expect. - # sourceBoundary: this trusted workflow owns host setup; the repository - # check only verifies and consumes expect without privilege. + # sourceBoundary: privileged host setup runs from the first-party + # host-dependency-setup action pinned to an immutable full SHA, never the + # repository target ref; the check only consumes expect without privilege. # whyNotSourceFix: GitHub-hosted jobs cannot use a repository-owned host # image, and caching privileged dpkg state between clean runners is not # supported. - # regressionTest: workflow-boundary tests pin the ordering and exact - # one-package apt allowlist. + # regressionTest: workflow-boundary tests pin the ordering, action + # provenance, and package mapping. # removalCondition: remove when the hosted runner supplies expect or the # cloud-onboard acceptance check no longer requires a PTY. - name: Install cloud-onboard DCode TUI host dependencies - shell: bash - run: | - set -euo pipefail - for attempt in 1 2 3; do - if sudo apt-get update; then - break - fi - if [ "$attempt" -eq 3 ]; then - echo "::error::apt-get update failed after 3 attempts." >&2 - exit 1 - fi - echo "::warning::apt-get update attempt ${attempt} failed; retrying." >&2 - sleep $((attempt * 5)) - done - sudo apt-get install -y --no-install-recommends expect + uses: NVIDIA/NemoClaw/.github/actions/host-dependency-setup@4def1501b34ce586f83b91af50a66b5d22b31d75 + with: + packages: expect - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 @@ -4009,32 +3929,21 @@ jobs: - *dockerhub-auth - # The #6194 terminal regression uses Expect as its PTY driver. Keep this - # privileged host setup inline in the reviewed workflow: the selected - # target ref may consume expect but cannot expand the package allowlist. + # The #6194 terminal regression uses Expect as its PTY driver. Privileged + # host setup runs from the first-party host-dependency-setup action pinned + # to an immutable full SHA: the selected target ref may consume expect but + # cannot expand the package allowlist or run sudo with its own code. # invalidState: hosted-runner Ubuntu mirrors can fail transiently during update. - # sourceBoundary: only this trusted workflow chooses the exact root-installed package. + # sourceBoundary: only the pinned trusted action chooses the exact root-installed package. # whyNotSourceFix: GitHub's Ubuntu image and configured repository move together, so a # fixed package version would make the target brittle across routine runner refreshes. # The runner's configured Ubuntu repository is therefore an accepted trust source. - # regressionTest: e2e-host-dependency-workflow-boundary rejects package or order drift. + # regressionTest: e2e-host-dependency-workflow-boundary rejects package or provenance drift. # removalCondition: remove this step when the hosted image provides Expect itself. - name: Install OpenClaw TUI host dependencies - shell: bash - run: | - set -euo pipefail - for attempt in 1 2 3; do - if sudo apt-get update; then - break - fi - if [ "$attempt" -eq 3 ]; then - echo "::error::apt-get update failed after 3 attempts." >&2 - exit 1 - fi - echo "::warning::apt-get update attempt ${attempt} failed; retrying." >&2 - sleep $((attempt * 5)) - done - sudo apt-get install -y --no-install-recommends expect + uses: NVIDIA/NemoClaw/.github/actions/host-dependency-setup@4def1501b34ce586f83b91af50a66b5d22b31d75 + with: + packages: expect - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 34b4796718..e24ab6c8c6 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -131,6 +131,16 @@ "test": "hermes-e2e: install.sh onboards Hermes and proves health plus live inference", "category": "security" }, + { + "file": "test/e2e/support/dockerhub-auth-workflow-boundary.test.ts", + "test": "binds the composite action and helper to their immutable reviewed revision (#6961)", + "category": "security" + }, + { + "file": "test/e2e/support/e2e-host-dependency-workflow-boundary.test.ts", + "test": "binds the host-dependency action and helper to their immutable reviewed revision (#6961)", + "category": "security" + }, { "file": "test/e2e/support/e2e-expected-state.test.ts", "test": "compiles absence probes for every preflight failure contract", diff --git a/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts b/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts index 12852e7e5c..bd002d6126 100644 --- a/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts +++ b/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts @@ -10,21 +10,35 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import YAML from "yaml"; -import { validateE2eWorkflowBoundary } from "../../../tools/e2e/workflow-boundary.mts"; +import { + validateDockerHubAuthAction, + validateE2eWorkflowBoundary, +} from "../../../tools/e2e/workflow-boundary.mts"; import { readWorkflow } from "../../helpers/e2e-workflow-contract"; const NO_IMAGE_E2E_JOBS = ["gateway-health-honest", "shared-e2e"] as const; const AUTH_STEP_NAME = "Authenticate to Docker Hub"; const CLEANUP_STEP_NAME = "Clean up Docker auth"; const CLEANUP_HELPER_RUN = "bash .github/scripts/docker-auth-cleanup.sh"; +const AUTH_HELPER_USES = + "NVIDIA/NemoClaw/.github/actions/docker-auth-setup@78091da47e290f49b8fe3f3e70b72362a0853928"; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); const CLEANUP_HELPER_PATH = path.join(REPO_ROOT, ".github", "scripts", "docker-auth-cleanup.sh"); +const AUTH_HELPER_PATH = path.join(REPO_ROOT, ".github", "scripts", "docker-auth-setup.sh"); +const AUTH_ACTION_PATH = path.join( + REPO_ROOT, + ".github", + "actions", + "docker-auth-setup", + "action.yaml", +); type WorkflowStep = Record & { env?: Record; name?: string; run?: string; uses?: string; + with?: Record; }; type WorkflowJob = { @@ -75,7 +89,68 @@ function writeExecutable(filePath: string, source: string): void { fs.chmodSync(filePath, 0o755); } -describe("shared Docker Hub authentication workflow boundary", () => { +function mutateAuthActionSource( + source: string, + mutateAction: (action: Record) => void, +): string { + const action = YAML.parse(source) as Record; + mutateAction(action); + return YAML.stringify(action); +} + +function validateAuthArtifactMutation(options: { + mutateAction?: (action: Record) => void; + mutateScript?: (source: string) => string; +}): string[] { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-docker-auth-action-")); + const actionPath = path.join(directory, "action.yaml"); + const scriptPath = path.join(directory, "docker-auth-setup.sh"); + try { + const actionSource = fs.readFileSync(AUTH_ACTION_PATH, "utf8"); + const mutatedActionSource = options.mutateAction + ? mutateAuthActionSource(actionSource, options.mutateAction) + : actionSource; + fs.writeFileSync(actionPath, mutatedActionSource); + const scriptSource = fs.readFileSync(AUTH_HELPER_PATH, "utf8"); + fs.writeFileSync(scriptPath, options.mutateScript?.(scriptSource) ?? scriptSource); + return validateDockerHubAuthAction(actionPath, scriptPath); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } +} + +describe("shared Docker Hub authentication workflow boundary (#6961)", () => { + // source-shape-contract: security -- Immutable credential-bearing action bytes must stay bound to reviewed commit provenance. + it("binds the composite action and helper to their immutable reviewed revision (#6961)", () => { + expect(validateDockerHubAuthAction()).toEqual([]); + + const mappingErrors = validateAuthArtifactMutation({ + mutateAction: (action) => { + const runs = action.runs as { steps: WorkflowStep[] }; + runs.steps[0].env = { + DOCKERHUB_AUTH_REQUIRED: "${{ inputs.auth-required }}", + DOCKERHUB_USERNAME: "${{ inputs.token }}", + DOCKERHUB_TOKEN: "${{ inputs.username }}", + }; + runs.steps[0].run = "bash .github/scripts/docker-auth-setup.sh"; + }, + }); + expect(mappingErrors).toContain( + "docker-auth-setup action content must match the action reviewed at its immutable commit pin", + ); + expect(mappingErrors).toContain( + "docker-auth-setup action must preserve its exact three-input environment mapping and pinned helper invocation", + ); + + expect( + validateAuthArtifactMutation({ + mutateScript: (source) => `${source}# unreviewed drift\n`, + }), + ).toContain( + "docker-auth-setup script content must match the helper reviewed at its immutable commit pin", + ); + }); + it("rejects missing auth and cleanup coverage for every classified image job", () => { const workflow = loadWorkflow(); const requiredJobs = imageJobNames(workflow); @@ -146,7 +221,7 @@ describe("shared Docker Hub authentication workflow boundary", () => { ); }); - it("rejects trust, isolation, retry, password, and cleanup mapping drift", () => { + it("rejects trust, helper, and cleanup mapping drift", () => { const errors = validateMutation((workflow) => { const auth = namedStep(workflow.jobs.live, AUTH_STEP_NAME); const cleanup = namedStep(workflow.jobs.live, CLEANUP_STEP_NAME); @@ -154,24 +229,12 @@ describe("shared Docker Hub authentication workflow boundary", () => { expect(cleanup).toBeDefined(); auth!.if = "github.event_name == 'schedule'"; - auth!.env = { - ...auth!.env, - DOCKERHUB_USERNAME: "${{ secrets.DOCKERHUB_USERNAME }}", + auth!.with = { + ...auth!.with, + username: "${{ secrets.DOCKERHUB_USERNAME }}", }; - auth!.run = String(auth!.run) - .replace( - "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX", - "${GITHUB_WORKSPACE}/docker-config", - ) - .replace("for attempt in 1 2 3; do", "for attempt in 1 2; do") - .replace( - 'auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"', - 'auth_marker="${GITHUB_WORKSPACE}/login-attempted"', - ) - .replace(': > "${auth_marker}"', 'touch "${auth_marker}"') - .replace('chmod 600 "${auth_marker}"', 'chmod 644 "${auth_marker}"') - .replace("--password-stdin", '--password "${DOCKERHUB_TOKEN}"') - .replaceAll("exit 1", "exit 0"); + auth!.uses = + "NVIDIA/NemoClaw/.github/actions/docker-auth-setup@0000000000000000000000000000000000000000"; cleanup!.if = "success()"; cleanup!.run = `${String(cleanup!.run)} || true`; @@ -188,18 +251,8 @@ describe("shared Docker Hub authentication workflow boundary", () => { expect(errors).toEqual( expect.arrayContaining([ "canonical Docker Hub auth step must always run so untrusted refs receive an isolated empty Docker config", - "canonical Docker Hub auth must gate DOCKERHUB_USERNAME on the trusted repository, main ref, and scheduled/manual events", - 'canonical Docker Hub auth run script must include mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX"', - "canonical Docker Hub auth directory must not use the checkout workspace", - "canonical Docker Hub auth run script must include for attempt in 1 2 3; do", - 'canonical Docker Hub auth run script must include auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"', - 'canonical Docker Hub auth run script must include : > "${auth_marker}"', - 'canonical Docker Hub auth run script must include chmod 600 "${auth_marker}"', - "canonical Docker Hub auth must create and protect its login-attempt marker after trusted credential validation and before login", - "canonical Docker Hub auth run script must include --password-stdin", - "canonical Docker Hub auth must pass the token only through --password-stdin", - "canonical Docker Hub auth must fail when trusted credentials are missing", - "canonical Docker Hub auth must fail after exhausting login retries", + "canonical Docker Hub auth must gate username on the trusted repository, main ref, and scheduled/manual events", + `canonical Docker Hub auth step must invoke only ${AUTH_HELPER_USES}`, "live Docker Hub cleanup step must contain exactly name, if, shell, and run", "live Docker Hub cleanup step must always run", `live Docker Hub cleanup step must run only ${CLEANUP_HELPER_RUN}`, @@ -208,6 +261,27 @@ describe("shared Docker Hub authentication workflow boundary", () => { ); }); + it("rejects Docker Hub credentials mapped without the checkout_sha guard", () => { + const errors = validateMutation((workflow) => { + const auth = namedStep(workflow.jobs.live, AUTH_STEP_NAME)!; + const ungatedPredicate = + "github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')"; + auth.with = { + "auth-required": `\${{ ${ungatedPredicate} && '1' || '0' }}`, + username: `\${{ ${ungatedPredicate} && secrets.DOCKERHUB_USERNAME || '' }}`, + token: `\${{ ${ungatedPredicate} && secrets.DOCKERHUB_TOKEN || '' }}`, + }; + }); + + expect(errors).toEqual( + expect.arrayContaining([ + "canonical Docker Hub auth must gate auth-required on the trusted repository, main ref, and scheduled/manual events", + "canonical Docker Hub auth must gate username on the trusted repository, main ref, and scheduled/manual events", + "canonical Docker Hub auth must gate token on the trusted repository, main ref, and scheduled/manual events", + ]), + ); + }); + it("rejects uniform unsafe cleanup drift without trusting the live job as canonical", () => { const workflow = loadWorkflow(); const requiredJobs = imageJobNames(workflow); @@ -247,7 +321,8 @@ describe("shared Docker Hub authentication workflow boundary", () => { it("executes the shared auth script with isolated config and bounded fail-closed retries", () => { const workflow = loadWorkflow(); - const authScript = String(namedStep(workflow.jobs.live, AUTH_STEP_NAME)?.run ?? ""); + expect(namedStep(workflow.jobs.live, AUTH_STEP_NAME)?.uses).toBe(AUTH_HELPER_USES); + expect(fs.statSync(AUTH_HELPER_PATH).mode & 0o111).not.toBe(0); const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-docker-auth-script-")); const fakeBin = path.join(directory, "bin"); const runnerTemp = path.join(directory, "runner-temp"); @@ -284,7 +359,7 @@ fi fs.rmSync(callsPath, { force: true }); fs.rmSync(tokensPath, { force: true }); fs.rmSync(githubEnv, { force: true }); - return spawnSync("bash", ["-c", authScript], { + return spawnSync(AUTH_HELPER_PATH, [], { encoding: "utf8", env: { ...process.env, @@ -351,6 +426,20 @@ fi expect(`${missing.stdout}${missing.stderr}`).toContain( "Docker Hub credentials are required for trusted E2E runs", ); + + const rejectedArgs = spawnSync(AUTH_HELPER_PATH, ["unexpected"], { + encoding: "utf8", + env: { + ...process.env, + DOCKERHUB_AUTH_REQUIRED: "0", + GITHUB_ENV: githubEnv, + GITHUB_JOB: "live", + PATH: `${fakeBin}:${process.env.PATH}`, + RUNNER_TEMP: runnerTemp, + }, + }); + expect(rejectedArgs.status).toBe(1); + expect(`${rejectedArgs.stdout}${rejectedArgs.stderr}`).toContain("does not accept arguments"); } finally { fs.rmSync(directory, { force: true, recursive: true }); } diff --git a/test/e2e/support/e2e-host-dependency-workflow-boundary.test.ts b/test/e2e/support/e2e-host-dependency-workflow-boundary.test.ts index 8e6c139871..c1c9df5ea5 100644 --- a/test/e2e/support/e2e-host-dependency-workflow-boundary.test.ts +++ b/test/e2e/support/e2e-host-dependency-workflow-boundary.test.ts @@ -1,14 +1,38 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + import { describe, expect, it } from "vitest"; +import YAML from "yaml"; -import { validateE2eWorkflow } from "../../../tools/e2e/workflow-boundary.mts"; +import { + validateE2eWorkflow, + validateHostDependencyAction, +} from "../../../tools/e2e/workflow-boundary.mts"; import { readWorkflow as readE2eWorkflow } from "../../helpers/e2e-workflow-contract.ts"; +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const ACTION_PATH = path.join( + REPO_ROOT, + ".github", + "actions", + "host-dependency-setup", + "action.yaml", +); +const SCRIPT_PATH = path.join(REPO_ROOT, ".github", "scripts", "host-dependency-setup.sh"); +const ACTION_USES = + "NVIDIA/NemoClaw/.github/actions/host-dependency-setup@4def1501b34ce586f83b91af50a66b5d22b31d75"; + interface WorkflowStep { name?: string; - run?: string; + uses?: string; + with?: Record; + "continue-on-error"?: boolean; } interface Workflow { @@ -28,43 +52,187 @@ function requireStepIndex(steps: WorkflowStep[], stepName: string): number { return index >= 0 ? index : throwMissingStep(stepName); } -describe("inline E2E host dependency boundary", () => { +function validateActionMutation(options: { + mutateAction?: (source: string) => string; + mutateScript?: (source: string) => string; +}): string[] { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-host-dependency-action-")); + const actionPath = path.join(directory, "action.yaml"); + const scriptPath = path.join(directory, "host-dependency-setup.sh"); + try { + const actionSource = fs.readFileSync(ACTION_PATH, "utf8"); + fs.writeFileSync(actionPath, options.mutateAction?.(actionSource) ?? actionSource); + const scriptSource = fs.readFileSync(SCRIPT_PATH, "utf8"); + fs.writeFileSync(scriptPath, options.mutateScript?.(scriptSource) ?? scriptSource); + return validateHostDependencyAction(actionPath, scriptPath); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } +} + +function writeExecutable(filePath: string, source: string): void { + fs.writeFileSync(filePath, source); + fs.chmodSync(filePath, 0o755); +} + +describe("E2E host dependency action boundary (#6961)", () => { + // source-shape-contract: security -- Privileged apt host setup must stay bound to the reviewed immutable action provenance. + it("binds the host-dependency action and helper to their immutable reviewed revision (#6961)", () => { + expect(validateHostDependencyAction()).toEqual([]); + + const mappingErrors = validateActionMutation({ + mutateAction: (source) => { + const action = YAML.parse(source) as Record; + const runs = action.runs as { steps: Array> }; + runs.steps[0].env = { HOST_DEPENDENCY_PACKAGES: "${{ inputs.packages }} curl" }; + return YAML.stringify(action); + }, + }); + expect(mappingErrors).toContain( + "host-dependency-setup action content must match the action reviewed at its immutable commit pin", + ); + expect(mappingErrors).toContain( + "host-dependency-setup action must preserve its exact single-input package mapping and pinned helper invocation", + ); + + expect( + validateActionMutation({ mutateScript: (source) => `${source}# unreviewed drift\n` }), + ).toContain( + "host-dependency-setup script content must match the helper reviewed at its immutable commit pin", + ); + }); + it.each([ { jobName: "live", stepName: "Install Deep Agents Code TUI host dependencies", - expected: - "live host dependency install must be exactly 'sudo apt-get install -y --no-install-recommends expect'", + packages: "expect", }, { jobName: "network-policy", stepName: "Install network-policy host dependencies", - expected: - "network-policy host dependency install must be exactly 'sudo apt-get install -y --no-install-recommends expect'", + packages: "expect", }, { jobName: "cloud-onboard", stepName: "Install cloud-onboard DCode TUI host dependencies", - expected: - "cloud-onboard host dependency install must be exactly 'sudo apt-get install -y --no-install-recommends expect'", + packages: "expect", }, { jobName: "issue-4434-tui-unreachable-inference", stepName: "Install issue #4434 host dependencies", - expected: - "issue-4434-tui-unreachable-inference host dependency install must be exactly 'sudo apt-get install -y --no-install-recommends expect iptables'", + packages: "expect iptables", }, { jobName: "openclaw-tui-chat-correlation", stepName: "Install OpenClaw TUI host dependencies", - expected: - "openclaw-tui-chat-correlation host dependency install must be exactly 'sudo apt-get install -y --no-install-recommends expect'", + packages: "expect", }, - ])("rejects package allowlist drift in $jobName", ({ jobName, stepName, expected }) => { + ])("rejects package allowlist drift in $jobName", ({ jobName, stepName, packages }) => { const workflow = readWorkflow(); const install = workflow.jobs[jobName]?.steps.find((step) => step.name === stepName)!; - install.run = (install.run ?? "").replace(/(sudo apt-get install[^\n]+)/u, "$1 curl"); - expect(validateE2eWorkflow(workflow)).toContain(expected); + install.with = { ...(install.with ?? {}), packages: `${packages} curl` }; + expect(validateE2eWorkflow(workflow)).toContain( + `${jobName} host dependency install must map only '${packages}'`, + ); + }); + + it("rejects host dependency setup that abandons the pinned action", () => { + const workflow = readWorkflow(); + const install = workflow.jobs.live?.steps.find( + (step) => step.name === "Install Deep Agents Code TUI host dependencies", + )!; + install.with = undefined; + install.uses = + "NVIDIA/NemoClaw/.github/actions/host-dependency-setup@0000000000000000000000000000000000000000"; + expect(validateE2eWorkflow(workflow)).toContain( + `live host dependency setup must invoke only ${ACTION_USES}`, + ); + }); + + it("rejects host dependency setup that tolerates failure with continue-on-error", () => { + const workflow = readWorkflow(); + const install = workflow.jobs.live?.steps.find( + (step) => step.name === "Install Deep Agents Code TUI host dependencies", + )!; + install["continue-on-error"] = true; + expect(validateE2eWorkflow(workflow)).toContain("live host dependency setup must fail closed"); + }); + + it("executes the host helper with validated packages and bounded retries (#6961)", () => { + expect(fs.statSync(SCRIPT_PATH).mode & 0o111).not.toBe(0); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-host-dependency-script-")); + const fakeBin = path.join(directory, "bin"); + const callsPath = path.join(directory, "sudo-calls"); + fs.mkdirSync(fakeBin); + writeExecutable(path.join(fakeBin, "sleep"), "#!/usr/bin/env bash\nexit 0\n"); + writeExecutable( + path.join(fakeBin, "sudo"), + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "\${SUDO_CALLS}" +if [[ "$1 $2" == "apt-get update" ]]; then + attempt="$(grep -c '^apt-get update$' "\${SUDO_CALLS}")" + if [[ "\${attempt}" -lt "\${APT_UPDATE_SUCCESS_ATTEMPT}" ]]; then + exit 1 + fi + exit 0 +fi +if [[ "$1 $2" == "apt-get install" ]]; then + exit 0 +fi +exit 64 +`, + ); + + const runSetup = (packages: string, successAttempt = 1, args: string[] = []) => { + fs.rmSync(callsPath, { force: true }); + return spawnSync(SCRIPT_PATH, args, { + encoding: "utf8", + env: { + ...process.env, + APT_UPDATE_SUCCESS_ATTEMPT: String(successAttempt), + HOST_DEPENDENCY_PACKAGES: packages, + PATH: `${fakeBin}:${process.env.PATH}`, + SUDO_CALLS: callsPath, + }, + }); + }; + + try { + const unexpectedArgument = runSetup("expect", 1, ["unexpected"]); + expect(unexpectedArgument.status).toBe(1); + expect(unexpectedArgument.stderr).toContain("does not accept arguments"); + expect(fs.existsSync(callsPath)).toBe(false); + + for (const invalidPackages of ["", " ", "expect\ncurl", "curl"]) { + const rejected = runSetup(invalidPackages); + expect(rejected.status).toBe(1); + expect(fs.existsSync(callsPath)).toBe(false); + } + + const retried = runSetup("expect iptables", 3); + expect(retried.status, retried.stderr).toBe(0); + expect(fs.readFileSync(callsPath, "utf8").trim().split("\n")).toEqual([ + "apt-get update", + "apt-get update", + "apt-get update", + "apt-get install -y --no-install-recommends expect iptables", + ]); + + const exhausted = runSetup("expect", 4); + expect(exhausted.status).toBe(1); + expect(fs.readFileSync(callsPath, "utf8").trim().split("\n")).toEqual([ + "apt-get update", + "apt-get update", + "apt-get update", + ]); + expect(`${exhausted.stdout}${exhausted.stderr}`).toContain( + "apt-get update failed after 3 attempts", + ); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } }); it("rejects installing the OpenClaw TUI host dependency after workspace preparation", () => { diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index b48767be5a..6ad8e00639 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -1,9 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { createHash } from "node:crypto"; import { readFileSync, statSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { isDeepStrictEqual } from "node:util"; import YAML from "yaml"; import { CREDENTIAL_FREE_TEST_TAG, @@ -39,6 +41,32 @@ import { validateUploadE2eArtifactsWorkflowBoundary } from "./upload-e2e-artifac const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const DEFAULT_E2E_WORKFLOW_PATH = join(REPO_ROOT, ".github", "workflows", "e2e.yaml"); +const DEFAULT_DOCKER_HUB_AUTH_ACTION_PATH = join( + REPO_ROOT, + ".github", + "actions", + "docker-auth-setup", + "action.yaml", +); +const DEFAULT_DOCKER_HUB_AUTH_SCRIPT_PATH = join( + REPO_ROOT, + ".github", + "scripts", + "docker-auth-setup.sh", +); +const DEFAULT_HOST_DEPENDENCY_ACTION_PATH = join( + REPO_ROOT, + ".github", + "actions", + "host-dependency-setup", + "action.yaml", +); +const DEFAULT_HOST_DEPENDENCY_SCRIPT_PATH = join( + REPO_ROOT, + ".github", + "scripts", + "host-dependency-setup.sh", +); type WorkflowRecord = Record; type WorkflowStep = WorkflowRecord & { @@ -92,11 +120,25 @@ const NO_IMAGE_E2E_JOBS = new Set(["gateway-health-honest", SHARED_E2E_JOB_ID]); const DOCKER_HUB_AUTH_STEP = "Authenticate to Docker Hub"; const DOCKER_HUB_CLEANUP_STEP = "Clean up Docker auth"; const DOCKER_HUB_CLEANUP_RUN = "bash .github/scripts/docker-auth-cleanup.sh"; +const DOCKER_HUB_AUTH_PROVENANCE = { + reference: + "NVIDIA/NemoClaw/.github/actions/docker-auth-setup@78091da47e290f49b8fe3f3e70b72362a0853928", + actionSha256: "cf93dcbd19589a56d1d58225fd6b3f8ad2180705662ff79a3407f340b5dba4c0", + scriptSha256: "853a3f742f057c29ed465b63bed1ec8d8f306a1c046877a8556cadf290ef0cb6", +} as const; +const DOCKER_HUB_AUTH_USES = DOCKER_HUB_AUTH_PROVENANCE.reference; +const HOST_DEPENDENCY_ACTION_PROVENANCE = { + reference: + "NVIDIA/NemoClaw/.github/actions/host-dependency-setup@4def1501b34ce586f83b91af50a66b5d22b31d75", + actionSha256: "1ac05a0e0a0159fa0850eb82fccb0704d0e49b15bc6f2d6e3b6bb04c7ab94923", + scriptSha256: "2e910ed80b5dcf9aaf94230371fe586376c46f6df8fcbd76229063cbda1852c8", +} as const; +const HOST_DEPENDENCY_ACTION_USES = HOST_DEPENDENCY_ACTION_PROVENANCE.reference; const DOCKER_HUB_CLEANUP_KEYS = ["if", "name", "run", "shell"]; // The general E2E workflow runs on schedule/manual dispatch. Its event set is // intentionally distinct from the reusable image workflow's push/manual boundary. const TRUSTED_DOCKER_HUB_PREDICATE = - "github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')"; + "github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && inputs.checkout_sha == ''"; const GUARDED_DOCKER_HUB_AUTH_REQUIRED = `\${{ ${TRUSTED_DOCKER_HUB_PREDICATE} && '1' || '0' }}`; const GUARDED_DOCKER_HUB_USERNAME = `\${{ ${TRUSTED_DOCKER_HUB_PREDICATE} && secrets.DOCKERHUB_USERNAME || '' }}`; const GUARDED_DOCKER_HUB_TOKEN = `\${{ ${TRUSTED_DOCKER_HUB_PREDICATE} && secrets.DOCKERHUB_TOKEN || '' }}`; @@ -108,6 +150,133 @@ function asRecord(value: unknown): WorkflowRecord { : {}; } +export function validateDockerHubAuthAction( + actionPath = DEFAULT_DOCKER_HUB_AUTH_ACTION_PATH, + scriptPath = DEFAULT_DOCKER_HUB_AUTH_SCRIPT_PATH, +): string[] { + const actionSource = readFileSync(actionPath, "utf8"); + const scriptSource = readFileSync(scriptPath, "utf8"); + const errors: string[] = []; + + if ( + createHash("sha256").update(actionSource).digest("hex") !== + DOCKER_HUB_AUTH_PROVENANCE.actionSha256 + ) { + errors.push( + "docker-auth-setup action content must match the action reviewed at its immutable commit pin", + ); + } + if ( + createHash("sha256").update(scriptSource).digest("hex") !== + DOCKER_HUB_AUTH_PROVENANCE.scriptSha256 + ) { + errors.push( + "docker-auth-setup script content must match the helper reviewed at its immutable commit pin", + ); + } + + const expectedAction = { + name: "docker-auth-setup", + description: "Authenticate to Docker Hub from an isolated per-job Docker config, fail closed.", + inputs: { + "auth-required": { + description: "Whether trusted Docker Hub credentials are present for this run.", + required: true, + }, + username: { + description: "Docker Hub username; only populated for trusted runs.", + required: false, + default: "", + }, + token: { + description: "Docker Hub token; only populated for trusted runs.", + required: false, + default: "", + }, + }, + runs: { + using: "composite", + steps: [ + { + name: "Authenticate to Docker Hub", + shell: "bash", + env: { + DOCKERHUB_AUTH_REQUIRED: "${{ inputs.auth-required }}", + DOCKERHUB_USERNAME: "${{ inputs.username }}", + DOCKERHUB_TOKEN: "${{ inputs.token }}", + }, + run: 'bash "${{ github.action_path }}/../../scripts/docker-auth-setup.sh"', + }, + ], + }, + }; + if (!isDeepStrictEqual(asRecord(YAML.parse(actionSource)), expectedAction)) { + errors.push( + "docker-auth-setup action must preserve its exact three-input environment mapping and pinned helper invocation", + ); + } + + return errors; +} + +export function validateHostDependencyAction( + actionPath = DEFAULT_HOST_DEPENDENCY_ACTION_PATH, + scriptPath = DEFAULT_HOST_DEPENDENCY_SCRIPT_PATH, +): string[] { + const actionSource = readFileSync(actionPath, "utf8"); + const scriptSource = readFileSync(scriptPath, "utf8"); + const errors: string[] = []; + + if ( + createHash("sha256").update(actionSource).digest("hex") !== + HOST_DEPENDENCY_ACTION_PROVENANCE.actionSha256 + ) { + errors.push( + "host-dependency-setup action content must match the action reviewed at its immutable commit pin", + ); + } + if ( + createHash("sha256").update(scriptSource).digest("hex") !== + HOST_DEPENDENCY_ACTION_PROVENANCE.scriptSha256 + ) { + errors.push( + "host-dependency-setup script content must match the helper reviewed at its immutable commit pin", + ); + } + + const expectedAction = { + name: "host-dependency-setup", + description: + "Install reviewed apt host dependencies with bounded retries from a trusted pinned action.", + inputs: { + packages: { + description: "Space-separated apt packages from the reviewed allowlist (expect, iptables).", + required: true, + }, + }, + runs: { + using: "composite", + steps: [ + { + name: "Install host dependencies", + shell: "bash", + env: { + HOST_DEPENDENCY_PACKAGES: "${{ inputs.packages }}", + }, + run: 'bash "${{ github.action_path }}/../../scripts/host-dependency-setup.sh"', + }, + ], + }, + }; + if (!isDeepStrictEqual(asRecord(YAML.parse(actionSource)), expectedAction)) { + errors.push( + "host-dependency-setup action must preserve its exact single-input package mapping and pinned helper invocation", + ); + } + + return errors; +} + function collectLiveTestFiles(value: unknown): string[] { if (typeof value === "string") return value.match(LIVE_TEST_FILE_PATTERN) ?? []; if (Array.isArray(value)) return value.flatMap(collectLiveTestFiles); @@ -525,7 +694,7 @@ function requireUploadPathDoesNotContain( } } -function validateInlineHostDependencyInstall( +function validateHostDependencyActionStep( errors: string[], jobName: string, steps: readonly WorkflowStep[], @@ -533,27 +702,27 @@ function validateInlineHostDependencyInstall( expectedPackages: readonly string[], ): void { const step = requireJobStep(errors, jobName, steps, stepName); - if (step?.uses) { - errors.push(`${jobName} host dependency setup must stay inline in trusted workflow YAML`); - } - for (const fragment of [ - "for attempt in 1 2 3", - "sudo apt-get update", - 'if [ "$attempt" -eq 3 ]; then', - "apt-get update failed after 3 attempts", - "sleep $((attempt * 5))", - ]) { - requireRunContains(errors, step, fragment); + if (!step) return; + if (step.uses !== HOST_DEPENDENCY_ACTION_USES) { + errors.push(`${jobName} host dependency setup must invoke only ${HOST_DEPENDENCY_ACTION_USES}`); + } + if (step.run !== undefined || step.shell !== undefined || step.env !== undefined) { + errors.push( + `${jobName} host dependency setup must invoke the pinned action, not an inline script`, + ); + } + if (step["continue-on-error"] !== undefined) { + errors.push(`${jobName} host dependency setup must fail closed`); } - const installPrefix = "sudo apt-get install -y --no-install-recommends "; - const installLines = stringValue(step?.run) - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter((line) => line.startsWith("sudo apt-get install ")); - const expectedInstall = `${installPrefix}${expectedPackages.join(" ")}`; - if (installLines.length !== 1 || installLines[0] !== expectedInstall) { - errors.push(`${jobName} host dependency install must be exactly '${expectedInstall}'`); + const withInputs = asRecord(step.with); + const expectedPackagesValue = expectedPackages.join(" "); + if (withInputs.packages !== expectedPackagesValue) { + errors.push(`${jobName} host dependency install must map only '${expectedPackagesValue}'`); + } + const unexpectedWith = Object.keys(withInputs).filter((name) => name !== "packages"); + if (unexpectedWith.length > 0) { + errors.push(`${jobName} host dependency setup must expose only the packages input`); } } @@ -1013,7 +1182,7 @@ function validateNetworkPolicyJob(errors: string[], jobs: WorkflowRecord): void errors.push("network-policy checkout step must set persist-credentials=false"); } - validateInlineHostDependencyInstall( + validateHostDependencyActionStep( errors, jobName, steps, @@ -1045,7 +1214,7 @@ function validateIssue4434HostDependencies(errors: string[], jobs: WorkflowRecor errors.push(`workflow missing ${jobName} job`); return; } - validateInlineHostDependencyInstall( + validateHostDependencyActionStep( errors, jobName, asSteps(job.steps), @@ -1065,7 +1234,7 @@ function validateOpenclawTuiChatCorrelationHostDependencies( return; } const steps = asSteps(job.steps); - validateInlineHostDependencyInstall( + validateHostDependencyActionStep( errors, jobName, steps, @@ -1994,11 +2163,10 @@ function requireCanonicalDockerHubAuthRun( "canonical Docker Hub auth step must always run so untrusted refs receive an isolated empty Docker config", ); } - if (authStep.shell !== "bash") { - errors.push("canonical Docker Hub auth step must use bash"); - } - if (authStep.uses !== undefined) { - errors.push("canonical Docker Hub auth step must use the audited inline retry script"); + if (authStep.run !== undefined || authStep.shell !== undefined || authStep.env !== undefined) { + errors.push( + "canonical Docker Hub auth step must invoke the pinned composite action, not an inline script", + ); } if (authStep["continue-on-error"] !== undefined) { errors.push( @@ -2006,108 +2174,31 @@ function requireCanonicalDockerHubAuthRun( ); } - const authEnv = asRecord(authStep.env); - if (authEnv.DOCKERHUB_AUTH_REQUIRED !== GUARDED_DOCKER_HUB_AUTH_REQUIRED) { - errors.push( - "canonical Docker Hub auth must gate DOCKERHUB_AUTH_REQUIRED on the trusted repository, main ref, and scheduled/manual events", - ); - } - if (authEnv.DOCKERHUB_USERNAME !== GUARDED_DOCKER_HUB_USERNAME) { - errors.push( - "canonical Docker Hub auth must gate DOCKERHUB_USERNAME on the trusted repository, main ref, and scheduled/manual events", - ); - } - if (authEnv.DOCKERHUB_TOKEN !== GUARDED_DOCKER_HUB_TOKEN) { - errors.push( - "canonical Docker Hub auth must gate DOCKERHUB_TOKEN on the trusted repository, main ref, and scheduled/manual events", - ); - } - const unexpectedEnv = Object.keys(authEnv).filter( - (name) => !["DOCKERHUB_AUTH_REQUIRED", "DOCKERHUB_USERNAME", "DOCKERHUB_TOKEN"].includes(name), - ); - if (unexpectedEnv.length > 0) { - errors.push("canonical Docker Hub auth step must expose only its three guarded inputs"); + if (authStep.uses !== DOCKER_HUB_AUTH_USES) { + errors.push(`canonical Docker Hub auth step must invoke only ${DOCKER_HUB_AUTH_USES}`); } - const runScript = stringValue(authStep.run); - for (const fragment of [ - 'mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX"', - 'chmod 700 "${docker_config}"', - 'export DOCKER_CONFIG="${docker_config}"', - 'if [[ "${DOCKERHUB_AUTH_REQUIRED}" != "1" ]]; then', - "continuing with anonymous pulls", - 'if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then', - 'auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"', - ': > "${auth_marker}"', - 'chmod 600 "${auth_marker}"', - "for attempt in 1 2 3; do", - "timeout 30s docker login docker.io", - '--username "${DOCKERHUB_USERNAME}"', - "--password-stdin", - "Docker Hub login failed after 3 attempts", - ]) { - if (!runScript.includes(fragment)) { - errors.push(`canonical Docker Hub auth run script must include ${fragment}`); - } - } - if ( - !runScript.includes("printf 'DOCKER_CONFIG=%s\\n'") || - !runScript.includes('"${DOCKER_CONFIG}"') || - !runScript.includes('>> "${GITHUB_ENV}"') - ) { + const authWith = asRecord(authStep.with); + if (authWith["auth-required"] !== GUARDED_DOCKER_HUB_AUTH_REQUIRED) { errors.push( - "canonical Docker Hub auth run script must persist the isolated DOCKER_CONFIG through GITHUB_ENV", + "canonical Docker Hub auth must gate auth-required on the trusted repository, main ref, and scheduled/manual events", ); } - if (runScript.includes("${{ github.workspace }}") || runScript.includes("GITHUB_WORKSPACE")) { - errors.push("canonical Docker Hub auth directory must not use the checkout workspace"); - } - if (/--password(?:=|\s)(?!-stdin\b)/u.test(runScript)) { - errors.push("canonical Docker Hub auth must pass the token only through --password-stdin"); - } - - const configIndex = runScript.indexOf( - 'mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX"', - ); - const trustIndex = runScript.indexOf('if [[ "${DOCKERHUB_AUTH_REQUIRED}" != "1" ]]; then'); - const loginIndex = runScript.indexOf("docker login docker.io"); - if (configIndex < 0 || trustIndex <= configIndex || loginIndex <= trustIndex) { + if (authWith.username !== GUARDED_DOCKER_HUB_USERNAME) { errors.push( - "canonical Docker Hub auth must isolate Docker config before evaluating trust and authenticating", + "canonical Docker Hub auth must gate username on the trusted repository, main ref, and scheduled/manual events", ); } - const missingCredentialsIndex = runScript.indexOf( - 'if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then', - ); - const missingCredentialsEndIndex = runScript.indexOf("\nfi", missingCredentialsIndex); - const markerPathIndex = runScript.indexOf( - 'auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"', - ); - const markerCreateIndex = runScript.indexOf(': > "${auth_marker}"'); - const markerChmodIndex = runScript.indexOf('chmod 600 "${auth_marker}"'); - const retryIndex = runScript.indexOf("for attempt in 1 2 3; do"); - const missingCredentialsBlock = - missingCredentialsIndex >= 0 && retryIndex > missingCredentialsIndex - ? runScript.slice(missingCredentialsIndex, retryIndex) - : ""; - if (!missingCredentialsBlock.includes("exit 1")) { - errors.push("canonical Docker Hub auth must fail when trusted credentials are missing"); - } - if ( - missingCredentialsEndIndex < 0 || - markerPathIndex <= missingCredentialsEndIndex || - markerCreateIndex <= markerPathIndex || - markerChmodIndex <= markerCreateIndex || - retryIndex <= markerChmodIndex || - loginIndex <= retryIndex - ) { + if (authWith.token !== GUARDED_DOCKER_HUB_TOKEN) { errors.push( - "canonical Docker Hub auth must create and protect its login-attempt marker after trusted credential validation and before login", + "canonical Docker Hub auth must gate token on the trusted repository, main ref, and scheduled/manual events", ); } - const exhaustedLoginIndex = runScript.indexOf("Docker Hub login failed after 3 attempts"); - if (exhaustedLoginIndex < 0 || !runScript.slice(exhaustedLoginIndex).includes("exit 1")) { - errors.push("canonical Docker Hub auth must fail after exhausting login retries"); + const unexpectedWith = Object.keys(authWith).filter( + (name) => !["auth-required", "username", "token"].includes(name), + ); + if (unexpectedWith.length > 0) { + errors.push("canonical Docker Hub auth step must expose only its three guarded inputs"); } } @@ -3943,7 +4034,7 @@ export function validateE2eWorkflow(workflowValue: unknown): string[] { steps, "Install Deep Agents Code TUI host dependencies", ); - validateInlineHostDependencyInstall( + validateHostDependencyActionStep( errors, "live", steps, @@ -4171,7 +4262,7 @@ export function validateE2eWorkflow(workflowValue: unknown): string[] { } const cloudOnboardSteps = asSteps(asRecord(jobs["cloud-onboard"]).steps); - validateInlineHostDependencyInstall( + validateHostDependencyActionStep( errors, "cloud-onboard", cloudOnboardSteps, @@ -4390,5 +4481,9 @@ export function validateE2eWorkflow(workflowValue: unknown): string[] { } export function validateE2eWorkflowBoundary(workflowPath = DEFAULT_E2E_WORKFLOW_PATH): string[] { - return validateE2eWorkflow(readWorkflowRecord(workflowPath)); + return [ + ...validateDockerHubAuthAction(), + ...validateHostDependencyAction(), + ...validateE2eWorkflow(readWorkflowRecord(workflowPath)), + ]; }