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
30 changes: 30 additions & 0 deletions .github/workflows/agent-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
14 changes: 13 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"'
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
4 changes: 3 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions internal/sandbox/images.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"claude": "ghcr.io/alexandre-daubois/embargod-agent@sha256:fb741e2e8d99e01d76861063debf5e4607f92f79f1484640ba33dd450922402c",
"codex": "ghcr.io/alexandre-daubois/embargod-agent@sha256:50a72751c6fc600ec63cc56d4bfd966cb7a40cca4397b61da714073f26d0c605"
}
24 changes: 19 additions & 5 deletions internal/sandbox/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package sandbox

import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion internal/sandbox/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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))
}
}
Loading