diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..524ef52 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +** +!go.mod +!go.sum +!cmd/ +!cmd/forge-gate/ +!cmd/forge-gate/** +!internal/ +!internal/buildinfo/ +!internal/buildinfo/** +!internal/configjson/ +!internal/configjson/** +!internal/gate/ +!internal/gate/** +!internal/githubdelivery/ +!internal/githubdelivery/** +!internal/processtree/ +!internal/processtree/** +!internal/protocol/ +!internal/protocol/** +!internal/store/ +!internal/store/** + +# Explicitly document excluded local and release material. +.git +dist +release +.env +.env.* +*secret* +*.db +gate.json +state +worktrees +evidence diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c33ad5c..491071e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,17 @@ permissions: contents: read jobs: + oci-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: OCI release helper self-tests + run: python3 scripts/oci-release-self-test.py + - name: Verify OCI Gate contract + run: scripts/oci-gate-contract.sh + - name: Native OCI Gate E2E + run: scripts/oci-gate-e2e.sh v999.0.0 "$GITHUB_SHA" + test: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4f8e20..79ef58c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,10 +13,10 @@ concurrency: cancel-in-progress: false jobs: - publish-linux: + prepare-linux: runs-on: ubuntu-latest permissions: - contents: write + contents: read id-token: write attestations: write steps: @@ -99,6 +99,160 @@ jobs: with: subject-checksums: release/SHA256SUMS sbom-path: release/agent-forge_${{ steps.identity.outputs.version }}_linux.spdx.json + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: agent-forge-linux-${{ github.sha }} + path: release/* + if-no-files-found: error + retention-days: 1 + + publish-oci-gate: + needs: prepare-linux + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + persist-credentials: false + - name: Validate exact annotated release tag + id: identity + shell: bash + run: | + set -euo pipefail + version="$GITHUB_REF_NAME" + commit="$(git rev-parse HEAD)" + scripts/validate-release-tag.sh "$version" "$commit" + printf 'version=%s\n' "$version" >>"$GITHUB_OUTPUT" + printf 'commit=%s\n' "$commit" >>"$GITHUB_OUTPUT" + - name: Require existing public GHCR package + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 scripts/ghcr-package-public.py + - name: Probe exact GHCR tag + id: tag + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ github.token }} + run: python3 scripts/ghcr-tag-state.py 0k-lab/agent-forge-gate "${{ steps.identity.outputs.version }}" >>"$GITHUB_OUTPUT" + - name: Refuse an existing stable tag + if: steps.tag.outputs.state != 'absent' + run: | + echo "OCI tag already exists; stable tags are never resumed or rerun. Correct the release and use the next patch version." >&2 + exit 1 + - name: Use isolated authenticated Docker config + run: | + set -euo pipefail + install -d -m 0700 "$RUNNER_TEMP/agent-forge-oci-auth" + printf 'DOCKER_CONFIG=%s\n' "$RUNNER_TEMP/agent-forge-oci-auth" >>"$GITHUB_ENV" + - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - name: Build and push multi-architecture Gate image + id: build + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 + with: + context: . + file: Dockerfile.gate + platforms: linux/amd64,linux/arm64 + push: true + pull: true + no-cache: true + sbom: true + provenance: mode=max + tags: ghcr.io/0k-lab/agent-forge-gate:${{ steps.identity.outputs.version }} + build-args: | + VERSION=${{ steps.identity.outputs.version }} + COMMIT=${{ steps.identity.outputs.commit }} + - name: Attest registry image provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-name: ghcr.io/0k-lab/agent-forge-gate + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + - name: Remove GHCR credentials + run: | + set -euo pipefail + [ "$DOCKER_CONFIG" = "$RUNNER_TEMP/agent-forge-oci-auth" ] + docker logout ghcr.io + rm -rf -- "$RUNNER_TEMP/agent-forge-oci-auth" + install -d -m 0700 "$RUNNER_TEMP/agent-forge-oci-anonymous" + printf 'DOCKER_CONFIG=%s\n' "$RUNNER_TEMP/agent-forge-oci-anonymous" >>"$GITHUB_ENV" + - name: Anonymously verify exact index + id: image + env: + IMAGE: ghcr.io/0k-lab/agent-forge-gate + VERSION: ${{ steps.identity.outputs.version }} + EXPECTED_DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + docker buildx imagetools inspect --raw "$IMAGE:$VERSION" >"$RUNNER_TEMP/agent-forge-gate-index.json" + python3 scripts/verify-oci-gate-release.py \ + "$RUNNER_TEMP/agent-forge-gate-index.json" "$EXPECTED_DIGEST" >>"$GITHUB_OUTPUT" + printf 'digest=%s\n' "$EXPECTED_DIGEST" >>"$GITHUB_OUTPUT" + - name: Anonymously validate both runtime images + env: + IMAGE: ghcr.io/0k-lab/agent-forge-gate + VERSION: ${{ steps.identity.outputs.version }} + COMMIT: ${{ steps.identity.outputs.commit }} + run: | + set -euo pipefail + for architecture in amd64 arm64; do + case "$architecture" in + amd64) digest="${{ steps.image.outputs.amd64_digest }}" ;; + arm64) digest="${{ steps.image.outputs.arm64_digest }}" ;; + esac + reference="$IMAGE@$digest" + docker pull --platform "linux/$architecture" "$reference" + [ "$(docker image inspect --format '{{.Architecture}}' "$reference")" = "$architecture" ] + [ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.source"}}' "$reference")" = https://github.com/0k-lab/agent-forge ] + [ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "$reference")" = "$VERSION" ] + [ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$reference")" = "$COMMIT" ] + [ "$(docker image inspect --format '{{.Config.User}}' "$reference")" = 65532:65532 ] + [ "$(docker image inspect --format '{{json .Config.Entrypoint}}' "$reference")" = '["/usr/local/bin/forge-gate"]' ] + [ "$(docker image inspect --format '{{json .Config.Cmd}}' "$reference")" = '["-config","/etc/agent-forge/gate.json"]' ] + [ "$(docker run --rm --platform "linux/$architecture" "$reference" --version)" = "forge-gate $VERSION $COMMIT" ] + done + docker buildx imagetools inspect --raw "$IMAGE:$VERSION" >"$RUNNER_TEMP/agent-forge-gate-index-recheck.json" + python3 scripts/verify-oci-gate-release.py \ + "$RUNNER_TEMP/agent-forge-gate-index-recheck.json" "${{ steps.build.outputs.digest }}" >/dev/null + + publish-linux: + needs: [prepare-linux, publish-oci-gate] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + persist-credentials: false + - name: Validate exact annotated release tag + id: identity + shell: bash + run: | + set -euo pipefail + version="$GITHUB_REF_NAME" + commit="$(git rev-parse HEAD)" + scripts/validate-release-tag.sh "$version" "$commit" + printf 'version=%s\n' "$version" >>"$GITHUB_OUTPUT" + printf 'commit=%s\n' "$commit" >>"$GITHUB_OUTPUT" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: agent-forge-linux-${{ github.sha }} + path: release + - name: Verify downloaded Linux release + run: python3 scripts/verify-linux-release.py "${{ steps.identity.outputs.version }}" release - name: Publish verified GitHub Release env: GITHUB_TOKEN: ${{ github.token }} diff --git a/Dockerfile.gate b/Dockerfile.gate new file mode 100644 index 0000000..6df6c7b --- /dev/null +++ b/Dockerfile.gate @@ -0,0 +1,42 @@ +FROM golang:1.24.4-bookworm@sha256:10f549dc8489597aa7ed2b62008199bb96717f52a8e8434ea035d5b44368f8a6 AS build + +ARG VERSION +ARG COMMIT +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd/forge-gate ./cmd/forge-gate +COPY internal/buildinfo ./internal/buildinfo +COPY internal/configjson ./internal/configjson +COPY internal/gate ./internal/gate +COPY internal/githubdelivery ./internal/githubdelivery +COPY internal/processtree ./internal/processtree +COPY internal/protocol ./internal/protocol +COPY internal/store ./internal/store +RUN printf '%s\n' "$VERSION" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \ + && printf '%s\n' "$COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + && CGO_ENABLED=0 GOFLAGS=-mod=readonly GOENV=off GOWORK=off GOEXPERIMENT= GOFIPS140=off GOTOOLCHAIN=go1.24.4 \ + go build -trimpath -buildvcs=false \ + -ldflags="-s -w -buildid= -X agent-forge/internal/buildinfo.Version=${VERSION} -X agent-forge/internal/buildinfo.Commit=${COMMIT}" \ + -o /out/forge-gate ./cmd/forge-gate + +FROM debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 + +ARG VERSION +ARG COMMIT +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists/* \ + && rm -rf /var/cache/apt/* \ + && printf 'agent-forge:x:65532:\n' >>/etc/group \ + && printf 'agent-forge:x:65532:65532:Agent Forge:/var/lib/agent-forge:/usr/sbin/nologin\n' >>/etc/passwd \ + && install -d -o 65532 -g 65532 -m 0700 /var/lib/agent-forge/state /var/lib/agent-forge/repositories \ + && install -d -o 0 -g 0 -m 0755 /etc/agent-forge +COPY --from=build /out/forge-gate /usr/local/bin/forge-gate +LABEL org.opencontainers.image.source="https://github.com/0k-lab/agent-forge" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${COMMIT}" +USER 65532:65532 +EXPOSE 18080 +ENTRYPOINT ["/usr/local/bin/forge-gate"] +CMD ["-config", "/etc/agent-forge/gate.json"] diff --git a/README.md b/README.md index 857c360..f583523 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A minimal Go vertical slice: submit a job to **forge-gate**, persist it in SQLit - **Gate** owns repository registrations and authorization, public-source clone/fetch/reuse, prepared local repository paths, Worker pools and authenticated slots, submission-time lifecycle/execution policy resolution, leases, exact GitHub App publication, CI observation/merge, and authoritative SQLite state. - **Worker** consumes Gate-prepared local repositories, edits, runs only instructed checks, commits locally, and returns the candidate SHA/evidence. External repository URLs and GitHub credentials never cross the Gate/Worker boundary; Worker never clones, pushes, or writes external APIs. - **Plugin** uses the strict NDJSON [plugin protocol v1](docs/plugin-protocol-v1.md). The reference plugin implements `text`; `forge-codex-plugin` implements `workspace_edit`, invokes `CODEX_BIN` (default `codex`) with bounded output and a timeout, obtains the actual-diff commit subject through Codex structured output, and never reports business success or commits. -- This MVP deliberately has no control panel, Docker, reviewer, mTLS, PostgreSQL, or plugin marketplace. Its only browser UI is a read-only debug viewer. GitHub delivery is optional. +- This MVP deliberately has no control panel, universal Worker image, reviewer, mTLS, PostgreSQL, or plugin marketplace. Its only browser UI is a read-only debug viewer. GitHub delivery is optional. ## Build and test @@ -46,7 +46,57 @@ The canonical matrix is: Every archive contains a `VERSION` file, and each runtime binary supports `--version`. CI runs `scripts/release-artifacts-e2e.sh` and uploads the verified full matrix as short-lived workflow artifacts. -A pushed annotated `vMAJOR.MINOR.PATCH` tag starts `.github/workflows/release.yml`; prerelease suffixes, lightweight tags, and commits outside `origin/main` are rejected. The tag pipeline selects the six Linux archives, writes a Linux-only `SHA256SUMS`, generates an SPDX JSON SBOM with pinned Syft, and creates GitHub build-provenance and SBOM attestations through OIDC. It then uploads the exact verified asset set to a draft GitHub Release, verifies every remote SHA-256 digest, and publishes the draft. Existing releases and assets are never replaced. If upload fails after draft creation, the partial draft is deliberately retained and a rerun refuses it; an operator must inspect it and explicitly decide whether to delete it before retrying. macOS is distributed through a Homebrew Formula and bottles rather than as GitHub Release assets; Cask and Apple notarization are not part of this CLI release path. +## OCI Gate image + +Gate is published separately as the publicly pullable multi-architecture image `ghcr.io/0k-lab/agent-forge-gate:vMAJOR.MINOR.PATCH`. The release workflow publishes only that exact version tag—never `latest`, major, or minor tags—and refuses any version tag that already exists. Failed stable tags are never resumed or rerun; correct the release and use the next patch version. This is workflow policy, not registry-enforced immutability. Pinning the resolved index digest remains the strongest production reference, for example `ghcr.io/0k-lab/agent-forge-gate@sha256:`. + +### One-time GHCR bootstrap before the first stable OCI release + +Use a GitHub token with `write:packages` and permission to publish for `0k-lab/agent-forge`. Keep it in `GHCR_TOKEN`, never in argv or the image. From a clean checkout of the exact reviewed `main` commit: + +```sh +export GHCR_USER='' +export GHCR_TOKEN='' +COMMIT=$(git rev-parse HEAD) +printf '%s' "$GHCR_TOKEN" | docker login ghcr.io --username "$GHCR_USER" --password-stdin +docker buildx build --file Dockerfile.gate \ + --platform linux/amd64,linux/arm64 --pull --no-cache --sbom=true --provenance=mode=max \ + --build-arg VERSION=v0.0.0 --build-arg COMMIT="$COMMIT" \ + --tag "ghcr.io/0k-lab/agent-forge-gate:bootstrap-$COMMIT" --push . +docker logout ghcr.io +unset GHCR_TOKEN +``` + +The pinned Dockerfile bases and source label create the package and link it to this repository. Confirm the repository link and make the package public in GitHub Package settings. Then use a fresh Docker config with no GHCR credentials to verify an anonymous pull of the bootstrap image: + +```sh +DOCKER_CONFIG=$(mktemp -d) +export DOCKER_CONFIG +docker pull "ghcr.io/0k-lab/agent-forge-gate:bootstrap-$COMMIT" +rm -rf "$DOCKER_CONFIG" +unset DOCKER_CONFIG +``` + +Confirm the package metadata reports public visibility; only then create the stable git tag. After the first stable image is published and verified, the bootstrap package version may be removed through GitHub Package settings. + +The stable-tag workflow only reads package metadata and fails before any image write when the package is absent or nonpublic. It does not and cannot change package visibility through GitHub's supported REST API. GHCR has no registry-enforced create-only or immutable tag operation: repository Actions concurrency is the single-writer control, the workflow checks absence through an authenticated registry request, and it verifies the exact index digest again after descriptor-bound runtime checks. Consumers that require cryptographic identity must pin the digest rather than trusting a mutable tag. + +The image runs as UID/GID `65532:65532`. Mount Gate config read-only, mount `/var/lib/agent-forge/state` writable, and supply tokens through environment variables or mounted secret files referenced by the runtime environment. The image contains Gate and Git for supported public-source/delivery operation; it does not contain Worker, CLI, or plugin binaries, and no universal Worker image is published. + +```sh +docker run --rm --read-only --cap-drop=ALL \ + --security-opt=no-new-privileges --tmpfs /tmp:rw,nosuid,nodev,noexec \ + -p 18080:18080 \ + --mount type=bind,src="$PWD/gate.json",dst=/etc/agent-forge/gate.json,readonly \ + --mount type=bind,src="$PWD/state",dst=/var/lib/agent-forge/state \ + --mount type=bind,src="$PWD/repositories",dst=/var/lib/agent-forge/repositories \ + -e FORGE_OWNER_TOKEN -e FORGE_WORKER_TOKEN \ + ghcr.io/0k-lab/agent-forge-gate:v0.1.0 +``` + +Create the host state and repositories directories owned by `65532:65532`; config should listen on `0.0.0.0:18080`, place SQLite under the mounted state directory, and use the mounted repositories directory for public-source storage. Keep secret values out of the image and config. + +A pushed annotated `vMAJOR.MINOR.PATCH` tag starts `.github/workflows/release.yml`; prerelease suffixes, lightweight tags, and commits outside `origin/main` are rejected. The tag pipeline selects the six Linux archives, writes a Linux-only `SHA256SUMS`, generates an SPDX JSON SBOM with pinned Syft, creates GitHub build-provenance and SBOM attestations through OIDC, and completes privileged disposable acceptance before any OCI push. It uploads those exact prepared assets as a short-lived workflow artifact, publishes and anonymously validates the Gate image, then downloads and revalidates the prepared assets before GitHub Release publication. Existing releases and assets are never replaced. If upload fails after draft creation, the partial draft is deliberately retained and the stable version is not rerun; an operator must inspect it, and any corrected release uses the next patch version. macOS is distributed through a Homebrew Formula and bottles rather than as GitHub Release assets; Cask and Apple notarization are not part of this CLI release path. ## Linux install and upgrade diff --git a/scripts/ghcr-package-public.py b/scripts/ghcr-package-public.py new file mode 100755 index 0000000..6ad835a --- /dev/null +++ b/scripts/ghcr-package-public.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Require the existing Agent Forge Gate GHCR package to be public.""" + +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + + +class PackageError(RuntimeError): + pass + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_args, **_kwargs): + return None + + +OPENER = urllib.request.build_opener(NoRedirect) + + +def require_public(api_base, organization, package, token): + url = (f"{api_base.rstrip('/')}/orgs/{urllib.parse.quote(organization, safe='')}" + f"/packages/container/{urllib.parse.quote(package, safe='')}") + request = urllib.request.Request(url, headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + }) + try: + with OPENER.open(request, timeout=30) as response: + status, body = response.status, response.read() + except urllib.error.HTTPError as error: + status, body = error.code, error.read() + except (OSError, urllib.error.URLError) as error: + raise PackageError(f"GitHub package metadata request failed: {error}") from error + if status == 404: + raise PackageError("GHCR package 0k-lab/agent-forge-gate does not exist; complete the README one-time public-package bootstrap before creating the stable tag") + if status != 200: + raise PackageError(f"GitHub package metadata request failed with HTTP {status}") + try: + metadata = json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise PackageError("GitHub package metadata response is malformed") from error + repository = metadata.get("repository") if isinstance(metadata, dict) else None + if (not isinstance(metadata, dict) + or metadata.get("name") != package + or metadata.get("package_type") != "container" + or metadata.get("visibility") != "public" + or not isinstance(repository, dict) + or repository.get("full_name") != f"{organization}/agent-forge"): + raise PackageError("GHCR package 0k-lab/agent-forge-gate is not public; complete the README one-time public-package bootstrap before creating the stable tag") + + +def main(): + if len(sys.argv) != 1: + raise PackageError("usage: ghcr-package-public.py") + token = os.environ.get("GITHUB_TOKEN", "") + if not token: + raise PackageError("GITHUB_TOKEN is required") + require_public("https://api.github.com", "0k-lab", "agent-forge-gate", token) + + +if __name__ == "__main__": + try: + main() + except PackageError as error: + print(f"GHCR package preflight: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/ghcr-tag-state.py b/scripts/ghcr-tag-state.py new file mode 100755 index 0000000..446f7da --- /dev/null +++ b/scripts/ghcr-tag-state.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Fail-closed GHCR manifest existence probe.""" + +import base64 +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request + + +ACCEPT = ", ".join(( + "application/vnd.oci.image.index.v1+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.docker.distribution.manifest.v2+json", +)) +DIGEST_RE = re.compile(r"sha256:[0-9a-f]{64}\Z") + + +class RegistryError(RuntimeError): + pass + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_args, **_kwargs): + return None + + +OPENER = urllib.request.build_opener(NoRedirect) + + +def request(url, headers): + try: + with OPENER.open(urllib.request.Request(url, headers=headers), timeout=30) as response: + return response.status, response.headers, response.read() + except urllib.error.HTTPError as error: + return error.code, error.headers, error.read() + except (OSError, urllib.error.URLError) as error: + raise RegistryError(f"registry request failed: {error}") from error + + +def bearer_parameters(challenge): + if not challenge or not challenge[:7].lower() == "bearer ": + raise RegistryError("registry did not return a Bearer authentication challenge") + parameters = {} + for item in urllib.request.parse_http_list(challenge[7:]): + key, separator, value = item.partition("=") + if not separator: + raise RegistryError("registry returned a malformed Bearer challenge") + parameters[key.strip().lower()] = value.strip().strip('"') + if not parameters.get("realm"): + raise RegistryError("registry Bearer challenge has no token realm") + return parameters + + +def validate_token_realm(registry, realm, allow_http=False): + registry_url = urllib.parse.urlsplit(registry) + realm_url = urllib.parse.urlsplit(realm) + if not allow_http and realm_url.scheme != "https": + raise RegistryError("registry token realm must use HTTPS") + if (realm_url.scheme, realm_url.hostname, realm_url.port) != ( + registry_url.scheme, registry_url.hostname, registry_url.port): + raise RegistryError("registry token realm must use the registry origin") + if realm_url.username or realm_url.password or realm_url.query or realm_url.fragment: + raise RegistryError("registry token realm is malformed") + return realm + + +def probe(registry, repository, reference, actor, token, allow_http=False): + if not allow_http and not registry.startswith("https://"): + raise RegistryError("registry URL must use HTTPS") + if not re.fullmatch(r"[a-z0-9]+(?:[._/-][a-z0-9]+)*", repository): + raise RegistryError("registry repository is malformed") + auth_url = f"{registry.rstrip('/')}/v2/" + status, headers, _ = request(auth_url, {"Accept": ACCEPT}) + if status != 401: + raise RegistryError(f"registry authentication challenge failed with HTTP {status}") + parameters = bearer_parameters(headers.get("WWW-Authenticate")) + realm = validate_token_realm(registry, parameters.pop("realm"), allow_http) + parameters["scope"] = f"repository:{repository}:pull" + token_url = realm + "?" + urllib.parse.urlencode(parameters) + basic = base64.b64encode(f"{actor}:{token}".encode()).decode() + token_status, _, body = request(token_url, {"Authorization": f"Basic {basic}"}) + if token_status != 200: + raise RegistryError(f"registry token request failed with HTTP {token_status}") + try: + bearer = json.loads(body)["token"] + except (KeyError, TypeError, ValueError, UnicodeDecodeError) as error: + raise RegistryError("registry token response is malformed") from error + if not isinstance(bearer, str) or not bearer: + raise RegistryError("registry token response is malformed") + manifest_url = f"{registry.rstrip('/')}/v2/{repository}/manifests/{urllib.parse.quote(reference, safe='')}" + status, headers, _ = request(manifest_url, {"Accept": ACCEPT, "Authorization": f"Bearer {bearer}"}) + if status == 404: + return "absent", "" + if status != 200: + raise RegistryError(f"registry manifest request failed with HTTP {status}") + digest = headers.get("Docker-Content-Digest", "") + if not DIGEST_RE.fullmatch(digest): + raise RegistryError("registry manifest response has no valid Docker-Content-Digest") + return "present", digest + + +def main(): + if len(sys.argv) != 3: + raise RegistryError("usage: ghcr-tag-state.py ") + actor = os.environ.get("GITHUB_ACTOR", "") + token = os.environ.get("GITHUB_TOKEN", "") + if not actor or not token: + raise RegistryError("GITHUB_ACTOR and GITHUB_TOKEN are required") + state, digest = probe("https://ghcr.io", sys.argv[1], sys.argv[2], actor, token) + print(f"state={state}") + if digest: + print(f"digest={digest}") + + +if __name__ == "__main__": + try: + main() + except RegistryError as error: + print(f"ghcr tag probe: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/oci-gate-contract.sh b/scripts/oci-gate-contract.sh new file mode 100755 index 0000000..32c2eb6 --- /dev/null +++ b/scripts/oci-gate-contract.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +DOCKERFILE=$ROOT/Dockerfile.gate +IGNORE=$ROOT/.dockerignore +CI=$ROOT/.github/workflows/ci.yml +RELEASE=$ROOT/.github/workflows/release.yml +README=$ROOT/README.md + +fail() { echo "oci gate contract: $*" >&2; exit 1; } +has() { grep -Fq -- "$2" "$1" || fail "$1 lacks: $2"; } +lacks() { ! grep -Eqi -- "$2" "$1" || fail "$1 contains forbidden pattern: $2"; } + +[[ -f $DOCKERFILE ]] || fail "Dockerfile.gate is missing" +[[ -f $IGNORE ]] || fail ".dockerignore is missing" +for helper in ghcr-tag-state.py ghcr-package-public.py verify-oci-gate-release.py oci-release-self-test.py; do + [[ -f $ROOT/scripts/$helper ]] || fail "$helper is missing" +done + +mapfile -t froms < <(awk 'toupper($1) == "FROM" {print $2}' "$DOCKERFILE") +[[ ${#froms[@]} -eq 2 ]] || fail "Dockerfile.gate must have exactly two stages" +[[ ${froms[0]} == 'golang:1.24.4-bookworm@sha256:10f549dc8489597aa7ed2b62008199bb96717f52a8e8434ea035d5b44368f8a6' ]] || fail "builder base is not exact" +[[ ${froms[1]} == 'debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171' ]] || fail "runtime base is not exact" +[[ $(awk 'toupper($1) == "USER" {user=$2} END {print user}' "$DOCKERFILE") == 65532:65532 ]] || fail "runtime user is not 65532:65532" + +for text in \ + 'CGO_ENABLED=0' 'GOFLAGS=-mod=readonly' 'GOENV=off' 'GOWORK=off' 'GOEXPERIMENT=' \ + 'GOFIPS140=off' 'GOTOOLCHAIN=go1.24.4' '-trimpath' '-buildvcs=false' '-buildid=' \ + 'internal/buildinfo.Version=${VERSION}' 'internal/buildinfo.Commit=${COMMIT}' \ + 'rm -rf /var/cache/apt/*' \ + 'USER 65532:65532' 'ENTRYPOINT ["/usr/local/bin/forge-gate"]' \ + 'CMD ["-config", "/etc/agent-forge/gate.json"]' 'EXPOSE 18080'; do + has "$DOCKERFILE" "$text" +done +has "$ROOT/scripts/oci-gate-e2e.sh" 'timeout --signal=TERM 10m' +has "$ROOT/scripts/oci-gate-e2e.sh" '--entrypoint /usr/bin/git' +has "$ROOT/scripts/oci-gate-e2e.sh" 'test -s /etc/ssl/certs/ca-certificates.crt' +has "$ROOT/scripts/oci-gate-e2e.sh" 'dst=/var/lib/agent-forge/repositories' +has "$README" 'dst=/var/lib/agent-forge/repositories' +has "$DOCKERFILE" "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" +has "$DOCKERFILE" "^[0-9a-f]{40}$" +has "$DOCKERFILE" 'org.opencontainers.image.source="https://github.com/0k-lab/agent-forge"' +has "$DOCKERFILE" 'org.opencontainers.image.version="${VERSION}"' +has "$DOCKERFILE" 'org.opencontainers.image.revision="${COMMIT}"' +lacks "$DOCKERFILE" 'forge-worker|forge-codex-plugin|forge-ref-plugin|cmd/forge([^/-]|$)|docker[.]io|healthcheck' + +for text in '.git' 'dist' 'release' '.env' '*secret*' '*.db' 'gate.json' 'state' 'worktrees' 'evidence'; do has "$IGNORE" "$text"; done + +has "$CI" 'oci-gate:' +has "$CI" 'scripts/oci-gate-contract.sh' +has "$CI" 'scripts/oci-gate-e2e.sh v999.0.0 "$GITHUB_SHA"' +has "$CI" 'python3 scripts/oci-release-self-test.py' +has "$RELEASE" 'publish-oci-gate:' +has "$RELEASE" 'prepare-linux:' +has "$RELEASE" 'needs: prepare-linux' +has "$RELEASE" 'needs: [prepare-linux, publish-oci-gate]' +prepare_line=$(grep -n '^ prepare-linux:' "$RELEASE" | cut -d: -f1) +oci_line=$(grep -n '^ publish-oci-gate:' "$RELEASE" | cut -d: -f1) +linux_line=$(grep -n '^ publish-linux:' "$RELEASE" | cut -d: -f1) +[[ $prepare_line -lt $oci_line && $oci_line -lt $linux_line ]] || fail "release jobs are not ordered prepare-linux -> publish-oci-gate -> publish-linux" +prepare_body=$(sed -n "${prepare_line},$((oci_line - 1))p" "$RELEASE") +oci_body=$(sed -n "${oci_line},$((linux_line - 1))p" "$RELEASE") +linux_body=$(sed -n "${linux_line},\$p" "$RELEASE") +grep -Fq 'scripts/build-release.sh' <<<"$prepare_body" || fail "prepare-linux does not build release assets" +grep -Fq 'linux-installer-privileged-e2e.sh' <<<"$prepare_body" || fail "prepare-linux lacks privileged acceptance" +grep -Fq 'actions/upload-artifact@' <<<"$prepare_body" || fail "prepare-linux does not upload prepared assets" +! grep -Fq 'publish-github-release.py' <<<"$prepare_body" || fail "prepare-linux publishes a GitHub Release" +grep -Fq 'actions/download-artifact@' <<<"$linux_body" || fail "publish-linux does not download prepared assets" +grep -Fq 'publish-github-release.py' <<<"$linux_body" || fail "publish-linux does not publish the GitHub Release" +grep -Fq 'OCI tag already exists; stable tags are never resumed' <<<"$oci_body" || fail "existing OCI tags are not a hard next-patch failure" +for text in \ + 'python3 scripts/ghcr-package-public.py' \ + 'python3 scripts/ghcr-tag-state.py 0k-lab/agent-forge-gate' \ + 'docker logout ghcr.io' \ + 'docker buildx imagetools inspect --raw "$IMAGE:$VERSION"' \ + 'python3 scripts/verify-oci-gate-release.py' \ + 'for architecture in amd64 arm64' \ + 'reference="$IMAGE@$digest"' \ + 'docker pull --platform "linux/$architecture" "$reference"'; do + grep -Fq "$text" <<<"$oci_body" || fail "publish-oci-gate lacks: $text" +done +[[ $(grep -Fc 'docker buildx imagetools inspect --raw "$IMAGE:$VERSION"' <<<"$oci_body") -eq 2 ]] || fail "exact OCI tag must be revalidated after both runtime checks" +package_line=$(grep -n 'python3 scripts/ghcr-package-public.py' <<<"$oci_body" | cut -d: -f1) +probe_line=$(grep -n 'python3 scripts/ghcr-tag-state.py' <<<"$oci_body" | cut -d: -f1) +push_line=$(grep -n 'docker/build-push-action@' <<<"$oci_body" | cut -d: -f1) +[[ $package_line -lt $push_line && $probe_line -lt $push_line ]] || fail "public-package and exact-tag preflights must precede OCI build/push" +has "$RELEASE" 'packages: write' +has "$RELEASE" 'id-token: write' +has "$RELEASE" 'attestations: write' +for action in \ + 'docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130' \ + 'docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f' \ + 'docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9' \ + 'docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8' \ + 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' \ + 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' \ + 'actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8'; do has "$RELEASE" "$action"; done +for text in \ + 'platforms: linux/amd64,linux/arm64' 'pull: true' 'no-cache: true' 'sbom: true' 'provenance: mode=max' \ + 'ghcr.io/0k-lab/agent-forge-gate:${{ steps.identity.outputs.version }}' \ + 'subject-name: ghcr.io/0k-lab/agent-forge-gate' 'push-to-registry: true'; do has "$RELEASE" "$text"; done +mapfile -t image_tags < <(awk '/^[[:space:]]+tags:[[:space:]]+/{sub(/^[[:space:]]+tags:[[:space:]]+/, ""); print}' "$RELEASE") +[[ ${#image_tags[@]} -eq 1 && ${image_tags[0]} == 'ghcr.io/0k-lab/agent-forge-gate:${{ steps.identity.outputs.version }}' ]] || fail "release workflow has a non-exact publication tag" +lacks "$RELEASE" 'agent-forge-gate:(latest|v?[0-9]+([.]v?[0-9]+)?)([[:space:]]|$)' +has "$README" 'One-time GHCR bootstrap before the first stable OCI release' +has "$README" 'make the package public in GitHub Package settings' +has "$README" 'verify an anonymous pull' +has "$README" 'only then create the stable git tag' +has "$README" 'registry-enforced immutability' +has "$README" 'docker login ghcr.io' +has "$README" 'bootstrap-$COMMIT' +has "$README" 'docker buildx build' +has "$README" '--push' +has "$ROOT/scripts/verify-oci-gate-release.py" 'vnd.docker.reference.digest' + +echo "oci gate contract: PASS" diff --git a/scripts/oci-gate-e2e.sh b/scripts/oci-gate-e2e.sh new file mode 100755 index 0000000..d7c7b0b --- /dev/null +++ b/scripts/oci-gate-e2e.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +on_error() { + local status=$? line=$1 command=$2 + printf '::error file=scripts/oci-gate-e2e.sh,line=%s::command failed (exit %s): %s\n' \ + "$line" "$status" "$command" >&2 + return "$status" +} +trap 'on_error "$LINENO" "$BASH_COMMAND"' ERR + +if [[ ${OCI_GATE_E2E_BOUNDED:-} != 1 ]]; then + exec timeout --signal=TERM 10m env OCI_GATE_E2E_BOUNDED=1 "$0" "$@" +fi + +usage() { echo "usage: $0 <40-lowercase-hex-commit>" >&2; exit 2; } +[[ $# -eq 2 ]] || usage +VERSION=$1 +COMMIT=$2 +[[ $VERSION =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || usage +[[ $COMMIT =~ ^[0-9a-f]{40}$ ]] || usage + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +RUN=$(mktemp -d) +SUFFIX=${RUN##*/} +IMAGE=agent-forge-gate-e2e:$SUFFIX +CONTAINER=agent-forge-gate-e2e-$SUFFIX +cleanup() { + local status=$? + trap - ERR + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + docker run --rm --user 0:0 --entrypoint /bin/chmod -v "$RUN:/cleanup" "$IMAGE" \ + -R a+rwx /cleanup/state /cleanup/repositories >/dev/null 2>&1 || true + docker image rm -f "$IMAGE" >/dev/null 2>&1 || true + if ! rm -rf -- "$RUN"; then + printf '::error file=scripts/oci-gate-e2e.sh::cleanup could not remove its temporary directory\n' >&2 + status=1 + fi + return "$status" +} +trap cleanup EXIT + +docker build --pull --no-cache --load --file "$ROOT/Dockerfile.gate" \ + --build-arg "VERSION=$VERSION" --build-arg "COMMIT=$COMMIT" --tag "$IMAGE" "$ROOT" + +[[ $(docker run --rm "$IMAGE" --version) == "forge-gate $VERSION $COMMIT" ]] +[[ $(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.source"}}' "$IMAGE") == https://github.com/0k-lab/agent-forge ]] +[[ $(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "$IMAGE") == "$VERSION" ]] +[[ $(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$IMAGE") == "$COMMIT" ]] +case $(uname -m) in x86_64) EXPECTED_ARCH=amd64;; aarch64|arm64) EXPECTED_ARCH=arm64;; *) echo "unsupported host architecture" >&2; exit 1;; esac +[[ $(docker image inspect --format '{{.Architecture}}' "$IMAGE") == "$EXPECTED_ARCH" ]] +[[ $(docker image inspect --format '{{.Config.User}}' "$IMAGE") == 65532:65532 ]] +[[ $(docker image inspect --format '{{json .Config.Entrypoint}}' "$IMAGE") == '["/usr/local/bin/forge-gate"]' ]] +[[ $(docker image inspect --format '{{json .Config.Cmd}}' "$IMAGE") == '["-config","/etc/agent-forge/gate.json"]' ]] +[[ $(docker run --rm --entrypoint /usr/bin/git "$IMAGE" --version) == git\ version\ * ]] +docker run --rm --entrypoint /bin/sh "$IMAGE" -eu -c 'test -s /etc/ssl/certs/ca-certificates.crt' +docker run --rm --entrypoint /bin/sh "$IMAGE" -eu -c \ + 'test -x /usr/local/bin/forge-gate; ! find / -xdev -type f \( -name "forge-worker" -o -name "forge-*-plugin" \) -print -quit 2>/dev/null | grep -q .' + +mkdir "$RUN/state" "$RUN/repositories" +docker run --rm --user 0:0 --entrypoint /bin/sh -v "$RUN:/host" "$IMAGE" -eu -c \ + 'chown 65532:65532 /host/state /host/repositories && chmod 0700 /host/state /host/repositories' +docker run --rm --read-only \ + --mount "type=bind,src=$RUN/repositories,dst=/var/lib/agent-forge/repositories" \ + --entrypoint /bin/sh "$IMAGE" -eu -c 'test -w /var/lib/agent-forge/repositories' +cat >"$RUN/gate.json" <<'JSON' +{"version":1,"listen":"0.0.0.0:18080","database":"/var/lib/agent-forge/state/forge.db","owner_token_env":"FORGE_OWNER_TOKEN","recovery_interval":"1s","lease_poll_interval":"100ms","default_pool":"coding","lifecycle":{"lease_ttl":"30s","retry_base":"1s","max_attempts":3},"default_execution":{"plugin_id":"reference","environment":[],"plugin_timeout":"15m","check_timeout":"10m","git_timeout":"1m","cleanup_timeout":"10s","plugin_output_bytes":1048576,"check_output_bytes":2048,"git_output_bytes":1048576},"workers":[{"id":"worker-1","pool":"coding","token_env":"FORGE_WORKER_TOKEN","concurrency":1}],"repositories":[]} +JSON +chmod 0444 "$RUN/gate.json" + +start_and_require_ready() { + docker run --detach --name "$CONTAINER" --read-only --cap-drop=ALL \ + --security-opt=no-new-privileges --tmpfs /tmp:rw,nosuid,nodev,noexec \ + --mount "type=bind,src=$RUN/gate.json,dst=/etc/agent-forge/gate.json,readonly" \ + --mount "type=bind,src=$RUN/state,dst=/var/lib/agent-forge/state" \ + --mount "type=bind,src=$RUN/repositories,dst=/var/lib/agent-forge/repositories" \ + --env FORGE_OWNER_TOKEN=oci-e2e-owner-token --env FORGE_WORKER_TOKEN=oci-e2e-worker-token \ + --publish 127.0.0.1:0:18080 "$IMAGE" >/dev/null + local port response + port=$(docker port "$CONTAINER" 18080/tcp | sed -n '1s/.*://p') + for _ in {1..60}; do + response=$(curl --fail --silent --show-error --max-time 2 "http://127.0.0.1:$port/readyz" 2>/dev/null || true) + [[ $response == '{"status":"ready"}' ]] && return + docker container inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null | grep -qx true || break + sleep 0.5 + done + docker logs "$CONTAINER" >&2 || true + return 1 +} + +start_and_require_ready +docker rm -f "$CONTAINER" >/dev/null +docker run --rm --entrypoint /usr/bin/test -v "$RUN/state:/state" "$IMAGE" -s /state/forge.db +start_and_require_ready +docker rm -f "$CONTAINER" >/dev/null +docker run --rm --entrypoint /usr/bin/test -v "$RUN/state:/state" "$IMAGE" -s /state/forge.db + +echo "oci gate e2e: PASS" diff --git a/scripts/oci-release-self-test.py b/scripts/oci-release-self-test.py new file mode 100755 index 0000000..8d7ee64 --- /dev/null +++ b/scripts/oci-release-self-test.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +import importlib.util +import hashlib +import json +import pathlib +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +SCRIPTS = pathlib.Path(__file__).parent + + +def load(name): + path = SCRIPTS / f"{name.replace('_', '-')}.py" + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +ghcr = load("ghcr_tag_state") +package = load("ghcr_package_public") +verify = load("verify_oci_gate_release") + + +class Registry: + def __init__(self, responses): + self.responses = list(responses) + self.requests = [] + + owner = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + owner.requests.append((self.path, self.headers)) + status, headers, body = owner.responses.pop(0) + self.send_response(status) + for key, value in headers.items(): + self.send_header(key, value.replace("{base}", owner.base)) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.base = f"http://127.0.0.1:{self.server.server_port}" + self.thread = threading.Thread(target=self.server.serve_forever) + + def __enter__(self): + self.thread.start() + return self + + def __exit__(self, *_args): + self.server.shutdown() + self.thread.join() + self.server.server_close() + + +DIGEST = "sha256:" + "a" * 64 + + +def authenticated_manifest(response): + challenge = 'Bearer realm="{base}/token",service="ghcr.io"' + return [ + (401, {"WWW-Authenticate": challenge}, b""), + (200, {}, json.dumps({"token": "bearer-secret"}).encode()), + response, + ] + + +def descriptor(architecture, digest_char, annotations=None): + result = { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:" + digest_char * 64, + "size": 123, + "platform": {"os": "linux" if architecture != "unknown" else "unknown", "architecture": architecture}, + } + if annotations: + result["annotations"] = annotations + return result + + +def raw_index(manifests=None): + amd64_digest = "sha256:" + "1" * 64 + arm64_digest = "sha256:" + "2" * 64 + value = { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": manifests or [ + descriptor("amd64", "1"), + descriptor("arm64", "2"), + descriptor("unknown", "3", { + "vnd.docker.reference.type": "attestation-manifest", + "vnd.docker.reference.digest": amd64_digest, + }), + descriptor("unknown", "4", { + "vnd.docker.reference.type": "attestation-manifest", + "vnd.docker.reference.digest": arm64_digest, + }), + ], + } + return json.dumps(value, separators=(",", ":")).encode() + + +class TagStateTests(unittest.TestCase): + def probe(self, responses): + with Registry(responses) as registry: + result = ghcr.probe( + registry.base, "0k-lab/agent-forge-gate", "v1.2.3", + "actor", "secret", allow_http=True, + ) + return result, registry.requests + + def test_manifest_404_is_absent(self): + result, _ = self.probe(authenticated_manifest((404, {}, b""))) + self.assertEqual(result, ("absent", "")) + + def test_anonymous_404_is_not_absent(self): + with self.assertRaises(ghcr.RegistryError): + self.probe([(404, {}, b"")]) + + def test_manifest_200_requires_and_returns_digest(self): + result, _ = self.probe(authenticated_manifest((200, {"Docker-Content-Digest": DIGEST}, b"{}"))) + self.assertEqual(result, ("present", DIGEST)) + + def test_bearer_challenge_uses_basic_only_for_token(self): + responses = authenticated_manifest((200, {"Docker-Content-Digest": DIGEST}, b"{}")) + result, requests = self.probe(responses) + self.assertEqual(result, ("present", DIGEST)) + self.assertNotIn("Authorization", requests[0][1]) + self.assertTrue(requests[1][1]["Authorization"].startswith("Basic ")) + self.assertEqual(requests[2][1]["Authorization"], "Bearer bearer-secret") + self.assertIn("scope=repository%3A0k-lab%2Fagent-forge-gate%3Apull", requests[1][0]) + + def test_manifest_failures_are_not_absent(self): + for status in (403, 429, 500, 502, 503): + with self.subTest(status=status): + with self.assertRaises(ghcr.RegistryError): + self.probe(authenticated_manifest((status, {}, b"failure"))) + + def test_manifest_redirect_to_404_is_not_absent(self): + with self.assertRaises(ghcr.RegistryError): + self.probe(authenticated_manifest((302, {"Location": "{base}/elsewhere"}, b""))) + + def test_bad_auth_and_digest_responses_fail(self): + cases = [ + [(401, {}, b"")], + [(401, {"WWW-Authenticate": "Basic realm=x"}, b"")], + authenticated_manifest((200, {}, b"{}")), + authenticated_manifest((200, {"Docker-Content-Digest": "sha256:bad"}, b"{}")), + ] + for responses in cases: + with self.subTest(responses=responses): + with self.assertRaises(ghcr.RegistryError): + self.probe(responses) + + def test_token_endpoint_failure_is_closed(self): + challenge = 'Bearer realm="{base}/token",service="ghcr.io",scope="repository:x:pull"' + with self.assertRaises(ghcr.RegistryError): + self.probe([ + (401, {"WWW-Authenticate": challenge}, b""), + (500, {}, b"failure"), + ]) + + def test_token_realm_must_match_registry_origin(self): + self.assertEqual( + ghcr.validate_token_realm("https://ghcr.io", "https://ghcr.io/token", False), + "https://ghcr.io/token", + ) + for realm in ( + "https://example.invalid/token", + "https://user@ghcr.io/token", + "https://ghcr.io/token#fragment", + ): + with self.subTest(realm=realm), self.assertRaises(ghcr.RegistryError): + ghcr.validate_token_realm("https://ghcr.io", realm, False) + + +class IndexTests(unittest.TestCase): + def test_valid_index_returns_exact_runtime_digests(self): + raw = raw_index() + expected = "sha256:" + hashlib.sha256(raw).hexdigest() + self.assertEqual(verify.verify_index(raw, expected), { + "amd64": "sha256:" + "1" * 64, + "arm64": "sha256:" + "2" * 64, + }) + + def test_raw_digest_must_match(self): + with self.assertRaises(verify.VerificationError): + verify.verify_index(raw_index(), DIGEST) + + def test_runtime_platforms_are_exact_and_unique(self): + variant = descriptor("arm64", "2") + variant["platform"]["variant"] = "v8" + bad_manifests = [ + [descriptor("amd64", "1"), descriptor("s390x", "2")], + [descriptor("amd64", "1"), descriptor("amd64", "2"), descriptor("arm64", "3")], + [descriptor("amd64", "1")], + [descriptor("amd64", "1"), variant], + ] + for manifests in bad_manifests: + with self.subTest(manifests=manifests): + raw = raw_index(manifests) + digest = "sha256:" + hashlib.sha256(raw).hexdigest() + with self.assertRaises(verify.VerificationError): + verify.verify_index(raw, digest) + + def test_only_marked_buildkit_attestations_are_allowed(self): + cases = [ + [descriptor("amd64", "1"), descriptor("arm64", "2")], + [descriptor("amd64", "1"), descriptor("arm64", "2"), descriptor("unknown", "3")], + [descriptor("amd64", "1"), descriptor("arm64", "2"), + descriptor("unknown", "3", {"vnd.docker.reference.type": "other"})], + [descriptor("amd64", "1"), descriptor("arm64", "2"), + descriptor("unknown", "3", { + "vnd.docker.reference.type": "attestation-manifest", + "vnd.docker.reference.digest": "sha256:" + "9" * 64, + })], + ] + for manifests in cases: + raw = raw_index(manifests) + digest = "sha256:" + hashlib.sha256(raw).hexdigest() + with self.assertRaises(verify.VerificationError): + verify.verify_index(raw, digest) + + def test_malformed_index_and_descriptors_fail(self): + cases = [b"not json", b"[]", raw_index([{}]), raw_index([descriptor("amd64", "x")])] + for raw in cases: + digest = "sha256:" + hashlib.sha256(raw).hexdigest() + with self.subTest(raw=raw): + with self.assertRaises(verify.VerificationError): + verify.verify_index(raw, digest) + + +class PackageVisibilityTests(unittest.TestCase): + def check(self, response): + with Registry([response]) as server: + package.require_public(server.base, "0k-lab", "agent-forge-gate", "secret") + return server.requests + + def test_exact_public_package_is_accepted(self): + body = json.dumps({ + "name": "agent-forge-gate", + "package_type": "container", + "visibility": "public", + "repository": {"full_name": "0k-lab/agent-forge"}, + }).encode() + requests = self.check((200, {}, body)) + self.assertEqual(requests[0][0], "/orgs/0k-lab/packages/container/agent-forge-gate") + self.assertEqual(requests[0][1]["Authorization"], "Bearer secret") + + def test_absent_or_nonpublic_package_fails_closed(self): + for response in [ + (404, {}, b'{"message":"Not Found"}'), + (200, {}, b'{"visibility":"private"}'), + (200, {}, b'{}'), + (200, {}, b'{"name":"other","package_type":"container","visibility":"public","repository":{"full_name":"0k-lab/agent-forge"}}'), + (200, {}, b'{"name":"agent-forge-gate","package_type":"npm","visibility":"public","repository":{"full_name":"0k-lab/agent-forge"}}'), + (200, {}, b'{"name":"agent-forge-gate","package_type":"container","visibility":"public","repository":{"full_name":"0k-lab/other"}}'), + (403, {}, b'forbidden'), + (500, {}, b'failure'), + ]: + with self.subTest(response=response), self.assertRaises(package.PackageError): + self.check(response) + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/verify-oci-gate-release.py b/scripts/verify-oci-gate-release.py new file mode 100755 index 0000000..72a5efb --- /dev/null +++ b/scripts/verify-oci-gate-release.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Verify the exact digest and descriptor contract of a Gate OCI index.""" + +import hashlib +import json +import pathlib +import re +import sys + + +DIGEST_RE = re.compile(r"sha256:[0-9a-f]{64}\Z") +INDEX_MEDIA_TYPES = { + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", +} +MANIFEST_MEDIA_TYPES = { + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", +} + + +class VerificationError(RuntimeError): + pass + + +def verify_index(raw, expected_digest): + actual_digest = "sha256:" + hashlib.sha256(raw).hexdigest() + if not DIGEST_RE.fullmatch(expected_digest) or actual_digest != expected_digest: + raise VerificationError(f"raw index digest mismatch: expected {expected_digest}, got {actual_digest}") + try: + index = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise VerificationError("index is not valid JSON") from error + if not isinstance(index, dict) or index.get("schemaVersion") != 2 or index.get("mediaType") not in INDEX_MEDIA_TYPES: + raise VerificationError("index header is malformed") + manifests = index.get("manifests") + if not isinstance(manifests, list): + raise VerificationError("index manifests are malformed") + + runtime = {} + attestation_references = [] + for descriptor in manifests: + if not isinstance(descriptor, dict) or descriptor.get("mediaType") not in MANIFEST_MEDIA_TYPES: + raise VerificationError("index contains a malformed manifest descriptor") + digest = descriptor.get("digest") + size = descriptor.get("size") + platform = descriptor.get("platform") + if (not isinstance(digest, str) or not DIGEST_RE.fullmatch(digest) + or not isinstance(size, int) or isinstance(size, bool) or size <= 0 + or not isinstance(platform, dict)): + raise VerificationError("index contains a malformed manifest descriptor") + os_name, architecture = platform.get("os"), platform.get("architecture") + if set(platform) != {"os", "architecture"}: + raise VerificationError("index contains a non-exact platform descriptor") + if (os_name, architecture) == ("unknown", "unknown"): + annotations = descriptor.get("annotations") + if not isinstance(annotations, dict) or annotations.get("vnd.docker.reference.type") != "attestation-manifest": + raise VerificationError("unknown/unknown descriptor is not a BuildKit attestation manifest") + reference_digest = annotations.get("vnd.docker.reference.digest") + if not isinstance(reference_digest, str) or not DIGEST_RE.fullmatch(reference_digest): + raise VerificationError("BuildKit attestation has no valid runtime reference digest") + attestation_references.append(reference_digest) + continue + if os_name != "linux" or architecture not in {"amd64", "arm64"}: + raise VerificationError(f"unexpected runtime platform: {os_name}/{architecture}") + if architecture in runtime: + raise VerificationError(f"duplicate runtime platform: linux/{architecture}") + runtime[architecture] = digest + if set(runtime) != {"amd64", "arm64"}: + raise VerificationError("runtime platforms must be exactly linux/amd64 and linux/arm64") + if sorted(attestation_references) != sorted(runtime.values()): + raise VerificationError("BuildKit attestations must bind exactly once to each runtime manifest") + return runtime + + +def main(): + if len(sys.argv) != 3: + raise VerificationError("usage: verify-oci-gate-release.py ") + runtime = verify_index(pathlib.Path(sys.argv[1]).read_bytes(), sys.argv[2]) + for architecture in ("amd64", "arm64"): + print(f"{architecture}_digest={runtime[architecture]}") + + +if __name__ == "__main__": + try: + main() + except (OSError, VerificationError) as error: + print(f"OCI Gate verification: {error}", file=sys.stderr) + sys.exit(1)