diff --git a/.github/workflows/agent-image.yml b/.github/workflows/agent-image.yml index 0dc310b..fff585b 100644 --- a/.github/workflows/agent-image.yml +++ b/.github/workflows/agent-image.yml @@ -51,3 +51,33 @@ jobs: subject-name: ghcr.io/${{ github.repository }}-agent subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true + + # Releases pin images by digest in internal/sandbox/images.json, so new images only reach users + # through a reviewed pull request. + pin: + needs: image + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Open a pull request pinning the new digests + env: + GH_TOKEN: ${{ github.token }} + IMAGE: ghcr.io/${{ github.repository }}-agent + run: | + set -euo pipefail + digest() { docker buildx imagetools inspect "$IMAGE:$1-$GITHUB_SHA" --format '{{json .Manifest.Digest}}' | tr -d '"'; } + jq -n --arg claude "$IMAGE@$(digest claude)" --arg codex "$IMAGE@$(digest codex)" '{claude: $claude, codex: $codex}' > internal/sandbox/images.json + if git diff --quiet; then + exit 0 + fi + branch="agent-images/$GITHUB_SHA" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git commit -am "Pin the agent images built from $GITHUB_SHA" + git push origin "$branch" + gh pr create --head "$branch" --title "Pin the agent images built from ${GITHUB_SHA:0:7}" --body "Pins the agent images the Agent image workflow published from $GITHUB_SHA." diff --git a/action.yml b/action.yml index 76fb644..08d0ad0 100644 --- a/action.yml +++ b/action.yml @@ -37,7 +37,7 @@ inputs: description: Turn on OpenAI's Daybreak cyber access program in Codex, with `engine: openai` only. The API key's org must be approved for it. default: "false" runner-image: - description: Image the agent runs in, defaults to ghcr.io/alexandre-daubois/embargod-agent with the tag of the engine's agent. It needs the agent CLI, git, curl and harness-proxy. + description: Image the agent runs in, instead of the embargod agent image pinned by digest and checked against its build attestation. It needs the agent CLI, git, curl and harness-proxy, and is not checked. required: false runs: @@ -52,6 +52,18 @@ runs: shell: bash working-directory: ${{ github.action_path }} run: go build -trimpath -o "$RUNNER_TEMP/embargod" ./cmd/embargod + # The default images are pinned by digest. This checks they were built by embargod's own workflow. + - name: Verify the agent image + if: inputs.runner-image == '' + shell: bash + working-directory: ${{ github.action_path }} + env: + ENGINE: ${{ inputs.engine }} + GH_TOKEN: ${{ github.token }} + run: | + case "$ENGINE" in openai) agent=codex ;; *) agent=claude ;; esac + image=$(jq -er --arg agent "$agent" '.[$agent]' internal/sandbox/images.json) + gh attestation verify "oci://$image" --repo alexandre-daubois/embargod --signer-workflow alexandre-daubois/embargod/.github/workflows/agent-image.yml - name: Triage shell: bash run: '"$RUNNER_TEMP/embargod"' diff --git a/docs/configuration.md b/docs/configuration.md index 91dc8f2..e91c230 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -14,7 +14,7 @@ | `engine` | `claude` | `claude` (Claude Code and the Anthropic API) or `openai` (Codex and the OpenAI API). | | `model` | `claude-opus-5-5` / `gpt-5.5` | Model used by both the agent and the duplicate search. | | `daybreak` | `false` | Turn on OpenAI's Daybreak cyber access program in Codex. `openai` engine only, and the API key's organisation must be approved for it. | -| `runner-image` | `ghcr.io/alexandre-daubois/embargod-agent:claude` or `:codex` | Image the agent runs in. It must contain the agent CLI, git, curl and `harness-proxy`. | +| `runner-image` | embargod agent image, pinned by digest | Image the agent runs in. It must contain the agent CLI, git, curl and `harness-proxy`. A custom image skips the attestation check. | Outside GitHub Actions, the same settings are read from environment variables: `LLM_API_KEY`, `GH_TOKEN`, `BOT_LOGIN`, `REPOSITORY`, `REPO_PATH`, `SINCE`, `ADVISORY`, `ENGINE`, `MODEL`, `DAYBREAK` and `RUNNER_IMAGE`. diff --git a/docs/development.md b/docs/development.md index bf09812..04de692 100644 --- a/docs/development.md +++ b/docs/development.md @@ -28,7 +28,9 @@ The agent images are built from [`docker/agent/Dockerfile`](../docker/agent/Dock docker build --target claude -t embargod-agent:claude docker/agent ``` -The `Agent image` workflow publishes both to `ghcr.io/alexandre-daubois/embargod-agent` when that directory changes on `main`. To bump an agent CLI, update its tag and the sha256 of each architecture's asset together. +The `Agent image` workflow publishes both to `ghcr.io/alexandre-daubois/embargod-agent` with a build attestation when that directory changes on `main`, then opens a pull request updating the digests in [`internal/sandbox/images.json`](../internal/sandbox/images.json). embargod only runs the images pinned there, so new images reach users once that pull request is merged and released. + +To bump an agent CLI, update its tag and the sha256 of each architecture's asset together in the Dockerfile. The workflow needs "Allow GitHub Actions to create and approve pull requests" in the repository settings. ## Running locally diff --git a/docs/security.md b/docs/security.md index e3548af..06d1d4c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -20,7 +20,8 @@ The agent runs through [harness](https://github.com/alpha-omega-security/harness - a throwaway container per advisory, with a read-only root filesystem, all capabilities dropped and no new privileges, - the repository mounted read-only, with `.git/config` hidden behind an empty file, - an internal network whose only route out is an authenticated egress proxy allowing the model proxy and `api.github.com`, -- an image containing only the agent CLI, git, curl, ripgrep and `harness-proxy`, with every download checked against a pinned checksum. +- an image containing only the agent CLI, git, curl, ripgrep and `harness-proxy`, with every download checked against a pinned checksum, +- that image pinned by digest in each release, and checked by the action against its build attestation before use. ## The model proxy diff --git a/internal/sandbox/images.json b/internal/sandbox/images.json new file mode 100644 index 0000000..5f10fa0 --- /dev/null +++ b/internal/sandbox/images.json @@ -0,0 +1,4 @@ +{ + "claude": "ghcr.io/alexandre-daubois/embargod-agent@sha256:fb741e2e8d99e01d76861063debf5e4607f92f79f1484640ba33dd450922402c", + "codex": "ghcr.io/alexandre-daubois/embargod-agent@sha256:50a72751c6fc600ec63cc56d4bfd966cb7a40cca4397b61da714073f26d0c605" +} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index e6f01a6..f99f838 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -5,6 +5,7 @@ package sandbox import ( "context" + _ "embed" "encoding/json" "errors" "fmt" @@ -38,15 +39,28 @@ type backend struct { keyEnv string // basePath completes the model proxy URL the way the CLI expects its API base URL. basePath string - // image is built from docker/agent and published by the agent-image workflow. - image string } var backends = map[string]backend{ - "claude": {"claude", modelproxy.Anthropic, "ANTHROPIC_API_KEY", modelproxy.PathPrefix, "ghcr.io/alexandre-daubois/embargod-agent:claude"}, - "codex": {"codex", modelproxy.OpenAI, "CODEX_API_KEY", modelproxy.PathPrefix + "/v1", "ghcr.io/alexandre-daubois/embargod-agent:codex"}, + "claude": {"claude", modelproxy.Anthropic, "ANTHROPIC_API_KEY", modelproxy.PathPrefix}, + "codex": {"codex", modelproxy.OpenAI, "CODEX_API_KEY", modelproxy.PathPrefix + "/v1"}, } +// imagesJSON pins each agent image by digest, so a version of embargod always runs the images built for it. +// The agent-image workflow updates it through a pull request after publishing new images. +// +//go:embed images.json +var imagesJSON []byte + +var Images = func() map[string]string { + var images map[string]string + if err := json.Unmarshal(imagesJSON, &images); err != nil { + panic("sandbox: invalid images.json: " + err.Error()) + } + + return images +}() + type Reader struct { backend backend harness harness.Harness @@ -87,7 +101,7 @@ func New(name, model, apiKey, image string) (*Reader, error) { return nil, errors.New("a container runtime (docker or podman) is required to run the agent") } if image == "" { - image = b.image + image = Images[name] } models, err := modelproxy.New(b.provider, apiKey) diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go index 901466b..f132259 100644 --- a/internal/sandbox/sandbox_test.go +++ b/internal/sandbox/sandbox_test.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strconv" "strings" "testing" @@ -107,7 +108,7 @@ func TestHostProxyReachesTheModelProxyOnly(t *testing.T) { func TestBackends(t *testing.T) { for name, wantKey := range map[string]string{"claude": "ANTHROPIC_API_KEY", "codex": "CODEX_API_KEY"} { b, ok := backends[name] - if !ok || b.keyEnv != wantKey || b.harness != name || b.image != "ghcr.io/alexandre-daubois/embargod-agent:"+name { + if !ok || b.keyEnv != wantKey || b.harness != name { t.Errorf("%s: %+v", name, b) } } @@ -144,3 +145,15 @@ func TestWriteConfig(t *testing.T) { }) } } + +func TestImagesArePinnedByDigest(t *testing.T) { + pinned := regexp.MustCompile(`^ghcr\.io/alexandre-daubois/embargod-agent@sha256:[0-9a-f]{64}$`) + for name := range backends { + if image := Images[name]; !pinned.MatchString(image) { + t.Errorf("%s: %q is not pinned by digest", name, image) + } + } + if len(Images) != len(backends) { + t.Errorf("images.json lists %d images for %d agents", len(Images), len(backends)) + } +}