Workload Identity Federation (WIF) setup¶
The two GitHub Actions producers — build-pod-image.yml and
build-qcow2.yml — authenticate to GCP via Workload Identity
Federation. There are no long-lived service-account keys in GitHub
secrets; the bindings are repo-scoped, so only the
safety-research/agent-escape-bench repo (or whichever fork sets up its
own bindings) can impersonate the SAs.
This page is a fork / new-org runbook: if you're working with the
canonical safety-research/agent-escape-bench repo, the bindings are
already in place and you don't need to repeat this. If you're
running this in a different GCP project (a fork, a new org, an
isolated test project), do the steps here once.
There are two SAs because the two workflows have different blast radii — splitting them lets the IAM bindings be tighter.
Prereqs (both SAs)¶
PROJECT=<your-gcp-project-id>
BUCKET=<your-public-bucket> # qcow2 cache; must already exist
GITHUB_REPO=<org>/<repo> # the only repo allowed to impersonate
# 1. Enable IAM Credentials API
gcloud services enable iamcredentials.googleapis.com --project=$PROJECT
# 2. Create the workload identity pool (one per project)
gcloud iam workload-identity-pools create github \
--project=$PROJECT --location=global \
--display-name="GitHub Actions"
# 3. Add a provider scoped to the GitHub org. Replace <org>.
gcloud iam workload-identity-pools providers create-oidc safety-research \
--project=$PROJECT --location=global \
--workload-identity-pool=github \
--display-name="safety-research org" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \
--attribute-condition="assertion.repository_owner == '<org>'" \
--issuer-uri="https://token.actions.githubusercontent.com"
# 4. Capture the pool resource name (needed below)
POOL=$(gcloud iam workload-identity-pools describe github \
--project=$PROJECT --location=global --format='value(name)')
echo "POOL=$POOL"
The provider's attribute-condition is the org-level guard. The
SA-level binding (add-iam-policy-binding) is the repo-level guard.
Both layers are needed.
Pod-image SA (gha-pod-image-pusher)¶
Used by .github/workflows/build-pod-image.yml. Pushes
escapebench-qemu:src-<hash> to Artifact Registry.
SA=gha-pod-image-pusher@${PROJECT}.iam.gserviceaccount.com
REGISTRY=us-central1-docker.pkg.dev/${PROJECT}/escapebench-public
# 1. Create the SA
gcloud iam service-accounts create gha-pod-image-pusher \
--project=$PROJECT --display-name="GHA pod image pusher"
# 2. Grant write on the Artifact Registry repo
gcloud artifacts repositories add-iam-policy-binding escapebench-public \
--location=us-central1 --project=$PROJECT \
--member="serviceAccount:$SA" \
--role="roles/artifactregistry.writer"
# 3. Bind WIF: only $GITHUB_REPO can impersonate
gcloud iam service-accounts add-iam-policy-binding "$SA" \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/$POOL/attribute.repository/$GITHUB_REPO" \
--project=$PROJECT
Then update .github/workflows/build-pod-image.yml's
workload_identity_provider and service_account to point at this
project.
qcow2-builder SA (gha-qcow2-builder)¶
Used by .github/workflows/build-qcow2.yml. Uploads qcow2 disks to
the public bucket. Cannot write .validated markers — those remain
a cluster privilege, enforced via a suffix-conditional binding.
SA=gha-qcow2-builder@${PROJECT}.iam.gserviceaccount.com
# 1. Create the SA
gcloud iam service-accounts create gha-qcow2-builder \
--project=$PROJECT --display-name="GHA qcow2 builder"
# 2. Grant suffix-conditional objectAdmin on the public bucket.
# The condition refuses any object name that ends in .validated —
# the marker is what flips a qcow2 from "uploaded" to "consumable",
# and the cluster's validation pipeline is the only thing that
# should set it.
gcloud storage buckets add-iam-policy-binding "gs://$BUCKET" \
--member="serviceAccount:$SA" \
--role="roles/storage.objectAdmin" \
--condition='expression=!resource.name.endsWith(".validated"),title=no_marker_writes,description=GHA SA must not write .validated markers' \
--project=$PROJECT
# 3. Bind WIF: only $GITHUB_REPO can impersonate
gcloud iam service-accounts add-iam-policy-binding "$SA" \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/$POOL/attribute.repository/$GITHUB_REPO" \
--project=$PROJECT
Update .github/workflows/build-qcow2.yml to point at this project.
Verifying the binding¶
After both SAs are set up, push a no-op change touching
images/pod/** (or trigger the workflow via workflow_dispatch)
and watch the "Authenticate to Google Cloud" step. It either
succeeds (binding works) or fails with a message that names whichever
of the three guards (provider attribute condition, SA workload
identity binding, role binding) wasn't satisfied — work backwards
from the error.
Why two SAs¶
| Pod image SA | qcow2-builder SA | |
|---|---|---|
| Resource | Artifact Registry repo | Public GCS bucket |
| Permission | artifactregistry.writer |
storage.objectAdmin w/ suffix condition |
Can write .validated markers? |
n/a (different bucket) | No — refused at IAM level |
| Failure blast radius | Pushes a bad pod image (caught by smoke test in workflow) | Uploads a bad qcow2 (inert until cluster validates and writes the marker) |
The split keeps the qcow2 bucket's marker-gate intact. Even if the qcow2 SA's keys were exfiltrated, an attacker couldn't promote a poisoned qcow2 to consumable — they'd need to also compromise a cluster-side identity that has marker-write permission.