Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.git
.github
.pytest_cache
.mypy_cache
.ruff_cache
__pycache__
*.pyc
*.db
*.log
dist
build
tests
docs
examples
experiments
benchmarks
17 changes: 15 additions & 2 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ on:
push:
tags:
- "v*"
pull_request:
paths:
- "Dockerfile"
- ".dockerignore"
- "pyproject.toml"
- "src/**"
- "schemas/**"
- ".github/workflows/docker.yml"

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
Expand All @@ -22,13 +30,15 @@ jobs:
uses: actions/checkout@v7

- name: Log in to GitHub Container Registry
if: startsWith(github.ref, 'refs/tags/')
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract tag name
if: startsWith(github.ref, 'refs/tags/')
id: tag
run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"

Expand All @@ -37,20 +47,23 @@ jobs:
uses: docker/build-push-action@v7
with:
context: .
push: true
push: ${{ startsWith(github.ref, 'refs/tags/') }}
tags: |
ghcr.io/agentrust-io/cmcp-gateway:${{ steps.tag.outputs.tag }}
ghcr.io/agentrust-io/cmcp-gateway:${{ steps.tag.outputs.tag || github.sha }}
ghcr.io/agentrust-io/cmcp-gateway:latest

- name: Install cosign
if: startsWith(github.ref, 'refs/tags/')
uses: sigstore/cosign-installer@v3

- name: Sign the image (keyless, by digest)
if: startsWith(github.ref, 'refs/tags/')
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: cosign sign --yes ghcr.io/agentrust-io/cmcp-gateway@${DIGEST}

- name: Attest build provenance (SLSA)
if: startsWith(github.ref, 'refs/tags/')
uses: actions/attest-build-provenance@v4
with:
subject-name: ghcr.io/agentrust-io/cmcp-gateway
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upstream stdio children and provenance verdicts are now cached by complete execution and trust identity rather than the non-unique human-readable `display_name`. Distinct catalog servers sharing a label can no longer reuse another server's process or provenance result.
- Built wheels now include the catalog-entry JSON Schema, and catalog loading fails closed if that schema is absent or unreadable. Previously source-tree tests validated catalog structure, but installed wheels omitted the schema and silently skipped that validation.
- PyPI publication now installs and smoke-tests the exact wheel and source distribution before upload, including release-tag/version agreement, import provenance, runtime configuration, and the packaged CLI.
- The runtime container now uses a patch-pinned Python slim base, builds a non-editable production wheelhouse in a separate stage, excludes development dependencies and build tooling from the final image, and runs as an unprivileged numeric UID/GID.

### Changed

Expand Down
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ clean environments before upload. It checks release-tag/version agreement,
metadata and runtime versions, import provenance outside the checkout, core
configuration construction, and the installed `cmcp` console entry point.

### Release container build

The release container uses a multi-stage build: the builder creates a wheelhouse
from production dependencies only, while the runtime installs it offline and
runs as numeric UID/GID 10001. Do not add editable installs, the `dev` extra, or
root execution to the runtime stage.
Dockerfile, schema, and container-workflow pull requests build the image without
registry credentials or a push; publishing, signing, and provenance attestation
remain restricted to version tags.

## Commit format

Follow [Conventional Commits](https://www.conventionalcommits.org/):
Expand Down
33 changes: 26 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
FROM python:3.11-slim
FROM python:3.11.15-slim-bookworm AS builder

WORKDIR /app
WORKDIR /build

# Install package
COPY pyproject.toml .
COPY pyproject.toml README.md LICENSE ./
COPY schemas/ schemas/
COPY src/ src/
RUN python -m pip install --upgrade pip setuptools && pip install -e ".[dev]"

# Config, policy, catalog injected via volume mounts
RUN mkdir -p /etc/cmcp
# Resolve runtime dependencies and build a non-editable wheelhouse. Development
# extras and build tooling never cross into the runtime stage.
RUN python -m pip wheel --disable-pip-version-check --wheel-dir /wheels .


FROM python:3.11.15-slim-bookworm AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

RUN groupadd --system --gid 10001 cmcp \
&& useradd --system --uid 10001 --gid cmcp --home-dir /var/lib/cmcp cmcp \
&& mkdir -p /etc/cmcp /var/lib/cmcp \
&& chown -R cmcp:cmcp /var/lib/cmcp

COPY --from=builder /wheels /wheels
RUN python -m pip install --disable-pip-version-check --no-index \
--find-links=/wheels cmcp-runtime \
&& rm -rf /wheels

WORKDIR /var/lib/cmcp
USER 10001:10001

EXPOSE 8443

Expand Down
59 changes: 59 additions & 0 deletions tests/unit/test_container_hardening.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Static release invariants for the CMCP runtime container."""

from pathlib import Path

import yaml


def _dockerfile() -> str:
return (Path(__file__).parents[2] / "Dockerfile").read_text(encoding="utf-8")


def test_runtime_image_uses_non_editable_production_install() -> None:
dockerfile = _dockerfile()

assert 'pip install -e ".[dev]"' not in dockerfile
assert "pip wheel" in dockerfile
assert "--no-index" in dockerfile


def test_runtime_image_drops_root_privileges() -> None:
dockerfile = _dockerfile()

assert "USER 10001:10001" in dockerfile
assert "useradd --system --uid 10001" in dockerfile


def test_runtime_image_pins_python_patch_and_distribution() -> None:
stages = [
line for line in _dockerfile().splitlines()
if line.startswith("FROM ")
]

assert stages == [
"FROM python:3.11.15-slim-bookworm AS builder",
"FROM python:3.11.15-slim-bookworm AS runtime",
]


def test_container_prs_build_without_registry_write() -> None:
workflow_path = Path(__file__).parents[2] / ".github" / "workflows" / "docker.yml"
workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
triggers = workflow[True]
steps = workflow["jobs"]["build-and-push"]["steps"]
build = next(step for step in steps if step.get("name") == "Build and push")

assert "pull_request" in triggers
assert build["with"]["push"] == "${{ startsWith(github.ref, 'refs/tags/') }}"
assert "steps.tag.outputs.tag || github.sha" in build["with"]["tags"]
assert "cmcp-gateway:latest" in build["with"]["tags"]
assert all(
"startsWith(github.ref, 'refs/tags/')" in step.get("if", "")
for step in steps
if step.get("name") in {
"Log in to GitHub Container Registry",
"Install cosign",
"Sign the image (keyless, by digest)",
"Attest build provenance (SLSA)",
}
)