Skip to content

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.