From fdef15c1a0fd0146110ccdc80da38e77e5a56813 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Tue, 11 Aug 2026 13:26:44 -0700 Subject: [PATCH] fix: add hardened runtime container --- .dockerignore | 19 +++++++++ .github/workflows/docker.yml | 35 +++++++++++++--- CHANGELOG.md | 7 ++++ Dockerfile | 30 +++++++++++++ docs/quickstart.md | 14 +++++++ tests/unit/test_container_release.py | 63 ++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 tests/unit/test_container_release.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5368f2b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.github +.venv +.mypy_cache +.pytest_cache +.ruff_cache +__pycache__ +*.py[cod] +*.egg-info +benchmarks +docs +examples +experiments +governance +overrides +scripts +tests +dist +site diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8895709..4c5c6e3 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,12 +4,37 @@ on: push: tags: - "v*" + pull_request: + paths: + - "Dockerfile" + - ".dockerignore" + - "pyproject.toml" + - "src/**" + - ".github/workflows/docker.yml" env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: + build-pr: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Build image without publishing + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + push: false + tags: ghcr.io/agentrust-io/ca2a-runtime:${{ github.sha }} + build-and-push: + if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest permissions: contents: read @@ -19,10 +44,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Log in to GitHub Container Registry - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -34,7 +59,7 @@ jobs: - name: Build and push id: build - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . push: true @@ -43,7 +68,7 @@ jobs: ghcr.io/agentrust-io/ca2a-runtime:latest - name: Install cosign - uses: sigstore/cosign-installer@v3 + uses: sigstore/cosign-installer@f713795cb21599bc4e5c4b58cbad1da852d7eeb9 # v3 - name: Sign the image (keyless, by digest) env: @@ -51,7 +76,7 @@ jobs: run: cosign sign --yes ghcr.io/agentrust-io/ca2a-runtime@${DIGEST} - name: Attest build provenance (SLSA) - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@8beda2b7ed98355c0e97c0a63bec38ae472e66c4 # v4 with: subject-name: ghcr.io/agentrust-io/ca2a-runtime subject-digest: ${{ steps.build.outputs.digest }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6791c85..58f4d04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and publishes only after those gates pass. Runtime `__version__` now comes from installed package metadata instead of a stale independent constant. +- Added the missing release container as a multi-stage, rootless image. Runtime + installation is offline from the builder wheelhouse and the build context + excludes VCS, tests, docs, and local environments. Pull requests now build the + image without registry/signing privileges, while tagged releases retain + version and `latest` tags, keyless signing, and provenance attestation. All + third-party container actions are pinned to immutable commits. + - **A delegation chain was a bearer credential: any party holding a copy was granted the leaf's authority.** The inbound path verified signatures, continuity, attenuation, depth and replay, then granted, without ever requiring the caller to demonstrate a relationship to the chain it presented. `PeerRequest` had no field that could carry such a proof, and `subject` — an Ed25519 public key — was only ever compared as a string for continuity, never used as a key. Chains are published deliberately: handed to auditors for offline verification, embedded in provenance DAGs, and shipped in `examples/`. So the credential intended for publication was the credential that granted authority. A chain lifted from any of those and replayed verbatim was accepted, and the provenance record emitted afterwards named the legitimate subject, so the audit trail attributed the call to the wrong party. Nothing was forged, so nothing failed a check and nothing anomalous reached a log; verbatim replay leaves no tamper evidence to find. `CREDENTIAL_REPLAY` does not cover it, catching only a duplicate `credential_id` inside one chain rather than replay of a whole valid chain by a different party. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0f4a0ed --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.11.15-slim-bookworm AS builder + +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /build +COPY pyproject.toml README.md LICENSE NOTICE ./ +COPY src ./src +RUN python -m pip wheel --wheel-dir /wheels . + +FROM python:3.11.15-slim-bookworm AS runtime + +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONUNBUFFERED=1 + +RUN groupadd --gid 10001 ca2a \ + && useradd --uid 10001 --gid 10001 --no-create-home --home-dir /var/lib/ca2a ca2a \ + && install -d -o ca2a -g ca2a /var/lib/ca2a /etc/ca2a + +COPY --from=builder /wheels /wheels +RUN python -m pip install --no-index --find-links=/wheels ca2a-runtime \ + && rm -rf /wheels + +USER 10001:10001 +WORKDIR /var/lib/ca2a +EXPOSE 8443 + +ENTRYPOINT ["ca2a"] +CMD ["--help"] diff --git a/docs/quickstart.md b/docs/quickstart.md index 961f047..4e4bf5e 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -8,6 +8,20 @@ This walkthrough builds a delegation chain and verifies it offline. It needs no pip install --pre ca2a-runtime ``` +Or run the published rootless container with a read-only configuration mount: + +```bash +docker run --rm -p 8443:8443 \ + --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m \ + -v "$PWD/ca2a-config.yaml:/etc/ca2a/config.yaml:ro" \ + ghcr.io/agentrust-io/ca2a-runtime:v0.1.0a1 \ + start --config /etc/ca2a/config.yaml +``` + +The image runs as UID/GID 10001. Hardware-backed providers additionally need +the relevant device passed through with permissions for that identity; do not +run the whole container as root to obtain device access. + cA2A is in alpha, so `--pre` is required to opt into the pre-release. Contributors working from a checkout can instead install from source: `pip install -e ".[dev]"`. ## Build an example chain diff --git a/tests/unit/test_container_release.py b/tests/unit/test_container_release.py new file mode 100644 index 0000000..9fe5afb --- /dev/null +++ b/tests/unit/test_container_release.py @@ -0,0 +1,63 @@ +"""Static guarantees for the releasable, least-privilege runtime container.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + + +def test_runtime_image_is_multistage_non_root_and_offline_installed() -> None: + dockerfile = Path("Dockerfile").read_text(encoding="utf-8") + assert dockerfile.count("FROM python:3.11.15-slim-bookworm") == 2 + assert "AS builder" in dockerfile + assert "pip wheel --wheel-dir /wheels ." in dockerfile + assert "pip install --no-index --find-links=/wheels ca2a-runtime" in dockerfile + assert "USER 10001:10001" in dockerfile + assert 'ENTRYPOINT ["ca2a"]' in dockerfile + + +def test_container_context_excludes_development_and_vcs_state() -> None: + ignored = set(Path(".dockerignore").read_text(encoding="utf-8").splitlines()) + assert {".git", ".github", ".venv", "tests", "docs", "examples", "dist"} <= ignored + + +def test_pull_requests_build_without_registry_or_signing_privileges() -> None: + workflow = yaml.safe_load(Path(".github/workflows/docker.yml").read_text(encoding="utf-8")) + triggers = workflow[True] + job = workflow["jobs"]["build-pr"] + steps = job["steps"] + build = next(step for step in steps if step.get("name") == "Build image without publishing") + + assert "pull_request" in triggers + assert job["permissions"] == {"contents": "read"} + assert build["with"]["push"] is False + assert "github.sha" in build["with"]["tags"] + assert all("login-action" not in step.get("uses", "") for step in steps) + assert all("cosign" not in step.get("uses", "") for step in steps) + + +def test_third_party_container_actions_are_pinned() -> None: + workflow = yaml.safe_load(Path(".github/workflows/docker.yml").read_text(encoding="utf-8")) + for job in workflow["jobs"].values(): + for step in job["steps"]: + uses = step.get("uses") + if uses: + ref = uses.rsplit("@", 1)[1].split()[0] + assert len(ref) == 40 + assert all(char in "0123456789abcdef" for char in ref) + + +def test_release_job_retains_version_latest_signing_and_attestation() -> None: + workflow = yaml.safe_load(Path(".github/workflows/docker.yml").read_text(encoding="utf-8")) + job = workflow["jobs"]["build-and-push"] + steps = job["steps"] + build = next(step for step in steps if step.get("name") == "Build and push") + names = {step.get("name") for step in steps} + + assert job["permissions"]["packages"] == "write" + assert job["permissions"]["id-token"] == "write" + assert build["with"]["push"] is True + assert "steps.tag.outputs.tag" in build["with"]["tags"] + assert "ca2a-runtime:latest" in build["with"]["tags"] + assert {"Sign the image (keyless, by digest)", "Attest build provenance (SLSA)"} <= names