System Design: Hardening and Architecting a Secure Multi-Tenant CI Runner Platform
Last verified2026-10 Also see: Build platform security (caches, BuildKit secrets, SSH into jobs) · Sandbox isolation (gVisor vs Firecracker vs Kata, escapes) · Nix and reproducible builds (hermetic builds, provenance) · Open-ended security design framework (the method this page applies) · Mock: threat-model a privileged node agent · GitHub & CI/CD security · System design foundations
DifficultyHard | Format: 45–60-minute open design problem, scored on structure, trust-boundary reasoning and trade-offs rather than on recall
Problem Statement
"Design a CI platform that runs customers' build jobs. Customers push code, including pull requests from strangers, and we run it. Jobs need root, Docker builds and the internet. We own bare-metal Linux racks (AMD EPYC and AmpereOne) and Apple Silicon machines for macOS builds, plus some capacity in AWS and GCP. Make it safe for us and for the other customers on the same hardware."
This page is a worked hour: what to say, in what order, and why. The isolation internals (how gVisor and microVMs differ, how escapes happen) live on the sandbox page; this page is about the system: control plane, token lifecycle, caches, egress, break-glass, provenance, and how the parts fail.
ImportantThe interviewer wants to see you treat the platform as what it is: remote code execution as a service, sold to strangers, next to other customers' secrets, with a network route to your internals. Every design decision should trace back to one of three questions: what can a job reach, what can a job keep, and what can a job pretend to be.
Start here: the basics in plain words
The process that picks up a job from a CI service (GitHub Actions, Buildkite) and executes its steps. On this platform the runner is yours, so you decide how it is isolated.
One unit of customer code execution: a shell script, a docker build, a test suite. Assume it is hostile; a pull request from a fork is literally code from a stranger.
A customer. Two tenants must never read each other's files, caches, network or credentials, even when their jobs run on the same physical machine.
The wall around a job. A microVM (Firecracker, or Apple's Virtualization framework on macOS) gives the job its own kernel; gVisor gives it a fake kernel; a plain container gives it the host's kernel, which is not enough here.
The part you trust: the API, the scheduler, the token broker, the provisioner. It never runs customer code.
The part that runs customer code: the hosts and the sandboxes on them. Assume any one sandbox is compromised at any time.
Network traffic leaving a job. Jobs need package registries; they must not reach your internal network, the cloud metadata service or other tenants.
A short-lived, verifiable identity for a job (an OIDC token) instead of a long-lived secret pasted into settings.
Memory hookreach, keep, pretendEvery control on this page limits one of them. Sandboxing and egress limit what a job can reach. Ephemeral VMs and partitioned caches limit what a job can keep. Single-use tokens and OIDC claims limit what a job can pretend to be.
What is this?
A multi-tenant CI runner platform is a fleet of machines that accept jobs from many customers' pipelines, execute them in isolation, give them controlled access to the network and to credentials, collect the outputs, and leave nothing behind. The hard part is that the workload is maximally privileged (root, nested containers, arbitrary network) and maximally untrusted (anyone can open a pull request), and the business wants it to be fast and cheap.
Why it matters
Interviewers use this problem because it touches every layer: hypervisor and kernel isolation, container internals, identity federation, network design, storage integrity, observability and incident response, with real trade-offs between security and the developer experience that pays for it. It is also the shape of several real companies, which is why the stack hints (Buildkite and GitHub Actions, gVisor and BuildKit, Apple Silicon with vz, EPYC and AmpereOne racks, Go services, OIDC) keep appearing.
How the hour goes
-
0–5 min · Restate and clarifybrowserdon't design yet
- One-sentence restatement; four questions whose answers change the architecture
-
5–12 min · Requirements, assumptions, numbersdnsscope the thing
- Tenancy model, hardware tiers, job shape, scale, what "safe" means here
-
12–22 min · Baseline architectureserverdraw control plane vs data plane
- Job lifecycle end to end, before any hardening
-
22–32 min · Threat modelattackwho attacks whom, through what
- Job → host, job → other tenant, job → your internals, job → cloud, you → customer
-
32–48 min · Deep dives where it mattersdefendranked by blast radius
- Isolation tiers, image builds, tokens, identity, egress, caches, break-glass, provenance
-
48–55 min · Trade-offs and failure modesrenderfail-closed vs fail-open, cost, latency
-
55–60 min · Summary and what you'd build firstrender
0–5 min: Restate and clarify
Restate in one sentence, then ask questions that change the design. Not "what language is the control plane in"; that changes nothing.
In practiceWhat you would actually say:
"So: we execute untrusted code from many customers on shared hardware, and the code expects root,
Docker and internet. I want to bound what a job can reach, keep and pretend to be. Four questions:
1. Do jobs come from GitHub Actions and Buildkite, or also our own API? ← decides the token model
2. Is the hardware shared between tenants at the same time, or can I dedicate hosts? ← decides sandbox tier
3. What must jobs reach: public registries only, or customer VPCs and private registries too? ← decides egress
4. What are the macOS constraints: Apple's two-VMs-per-host rule, and do customers need their own images?"Assume answers: both CI services plus a thin API of your own; hardware is shared, dedicated hosts are a premium option; public registries plus an optional customer-provided allowlist; macOS is for mobile builds with your images.
5–12 min: Requirements, assumptions and numbers
FunctionalRun a job to completion with logs streamed back; support Linux x86-64, Linux arm64 and macOS arm64; docker build inside jobs; per-job secrets from the CI service; caching to make repeat builds fast; a way for a developer to debug a stuck job.
Security requirements, stated as invariants you will defend later:
- A job cannot read or write anything of another tenant's: files, caches, network traffic, credentials.
- A job cannot persist anything on the host across jobs.
- A job cannot reach the control plane, host management interfaces, cloud metadata services or RFC 1918 space except through explicit, logged allowlists.
- A credential issued to a job is useless after the job ends and useless from anywhere else.
- Everything the platform does to a job is attributable and logged outside the job's reach.
Numbers, so the design has a scale. Say them as assumptions:
jobs/day ~1,000,000 (assumption; say it out loud)
mean duration ~6 min, p95 30 min
concurrency 1e6 × 6 min / 1440 min ≈ 4,200 jobs running at once; plan for 3× burst ≈ 12,000
Linux host EPYC 128 cores / AmpereOne 192 cores → ~30–60 sandboxes per host at 2–4 vCPU each
macOS host 2 macOS guests per machine (Apple's licence and the framework cap) → macOS is capacity-bound, not CPU-bound
cold sandbox start Firecracker microVM ~150 ms to 1 s; gVisor pod ~0.5–1 s; macOS vz guest from a template 20–60 sThe last line shapes the macOS design: you pre-boot and pool macOS guests, which means the "fresh VM per job" invariant needs a reset story, not just a boot story.
12–22 min: Baseline architecture
Draw it before hardening it. Control plane on the left, data plane on the right, and arrows that only go one way where you can manage it.
CONTROL PLANE (trusted; never runs customer code)
┌──────────────────────────────────────────────────────────────────────────────────────────┐
│ Webhook/API ──► Scheduler ──► Provisioner ──► Token broker ──► Attestation / audit sink │
│ (GitHub app, (queue per (boots a (mints single-use (signs provenance, │
│ Buildkite tenant, host sandbox on a runner regs, job stores logs where │
│ cluster API, picking, chosen host, OIDC, cache creds) jobs can't reach) │
│ own API) quotas) injects config) │
└───────────┬──────────────────────────┬─────────────────────────────────┬──────────────────┘
│ gRPC, mTLS, SPIFFE ids │ host agent API (one-way: control plane → host) │
▼ ▼ ▼
═══════════ DATA PLANE (assume compromised) ══════════════════════════════════════════════════
Linux rack (EPYC / AmpereOne) Apple Silicon rack Cloud burst (AWS/GCP)
┌────────────────────────────────┐ ┌────────────────────────┐ ┌─────────────────────┐
│ host OS (minimal, immutable) │ │ macOS host │ │ bare-metal or nitro │
│ host agent (Go) │ │ host agent (Go) │ │ instances only; │
│ ┌────────┐ ┌────────┐ │ │ ┌─────────┐ ┌───────┐ │ │ IMDS firewalled │
│ │ microVM│ │ microVM│ …×40 │ │ │ vz guest│ │vz gst │ │ │ from guests │
│ │ runner │ │ runner │ │ │ │ runner │ │runner │ │ └─────────────────────┘
│ │ job │ │ job │ │ │ │ job │ │job │ │
│ └───┬────┘ └───┬────┘ │ │ └────┬────┘ └───┬───┘ │
│ └─────┬────┘ tap/bridge │ │ └────┬─────┘ │
└────────────┼───────────────────┘ └────────────┼───────────┘
▼ ▼
┌──────────────── egress tier ────────────────────────────┐
│ per-job policy proxy (Go) → allowlisted hosts only │
│ dedicated egress IP pools per tenant (for their allowlists)│
│ default-deny to RFC1918, 169.254.0.0/16, control plane │
└────────────────────────┬─────────────────────────────────┘
▼
internet: registries, GitHub, customer endpoints
Side services (reached only via the proxy or a per-job credential):
cache store (per-tenant buckets) artifact store package mirrors log/attestation sink (write-only from jobs)The job lifecycle, before hardening
-
1. EventserverGitHub
workflow_job.queuedwebhook, Buildkite job via cluster queue, or own API call -
2. Scheduleserverpick a host with capacity for the job's platform and tenant policy
-
3. Mint identitytlssingle-use runner registration, job-scoped OIDC audience, cache credentials
-
4. Provisionoshost agent boots a sandbox from a golden image with the config injected
-
5. Runattackthe runner inside registers, pulls the job, executes customer steps as root inside the sandbox
-
6. Egressnetworkall traffic leaves via the policy proxy
-
7. Collectserverlogs streamed out, artifacts and cache entries written to tenant storage
-
8. Destroydefendsandbox killed, disk discarded, host agent verifies nothing remains, credentials revoked
22–32 min: Threat model
Draw the boundaries on the diagram, then walk the attacks across each one. Say the attacker and the target before the mechanism.
| # | Attacker → target | Mechanism | Primary control | Secondary control |
|---|---|---|---|---|
| T1 | Job → host kernel | Kernel bug reachable through syscalls; runc/BuildKit escape | Hardware virtualisation (microVM / vz) so the guest kernel is sacrificial | gVisor inside the VM for defence in depth on Linux; minimal host OS; SMT scheduling per tenant |
| T2 | Job → other tenant on same host | Side channels; shared caches; shared network segment | Per-job VM, per-tenant network segment, per-tenant cache namespaces | Dedicated hosts for premium tenants; no two tenants on sibling hyperthreads |
| T3 | Job → host persistence | Writing to host disk, firmware, BMC | Immutable host image, ephemeral VM disks, no job-writable host paths | Measured boot, periodic host re-image, BMC on an isolated management network |
| T4 | Job → your control plane | Network route to scheduler/API; stolen host-agent credential | Default-deny egress; control plane reachable only from host agents over mTLS, one-way | Host-agent credential bound to that host's TPM; per-host authorisation |
| T5 | Job → cloud metadata / cloud account | IMDS at 169.254.169.254 from a cloud-hosted sandbox | Block link-local from guests; IMDSv2 with hop limit 1; no instance role with real permissions | Alert on any IMDS hit from the job network |
| T6 | Job → customer's cloud (over-scoped OIDC) | Token's sub matches a broad trust policy | Customer guidance plus audience and claim pinning; immutable repository IDs in claims | Platform-side telemetry on which audiences jobs request |
| T7 | Job A → Job B via cache | Poisoned dependency layer restored by a trusted build | Cache keys include tenant, repo and trust class; PR caches never readable by base builds | Content-addressed entries; signed manifests; cache is data, never executed on restore |
| T8 | Job → credential theft from runner process | Dumping runner memory, reading env | Secrets fetched per step and held briefly; sandbox is single-tenant so theft is self-theft | Short token lifetimes; outbound beacon detection at the proxy |
| T9 | Platform → customer (your own insiders, your compromise) | Reading job contents, altering artifacts | Provenance signed by a controller, logged outside operator reach; break-glass access recorded | Customer-verifiable attestations; separation of duties for golden images |
| T10 | Supply chain → platform | Malicious base image, runner binary, action | Pinned digests for everything the platform itself runs; reproducible golden images | Rebuild and compare on a second builder |
🎯On the job. T4 is the one candidates forget and attackers love. If a sandbox can open a TCP connection to the scheduler's gRPC port, every other control is one authentication bug away from irrelevant. Make the host agent call out to the control plane; never let the control plane be reachable from the job network.
📰Real incident — Codecov Bash Uploader (2021). The pieces you need to know. Many CI jobs ended with
curl -s https://codecov.io/bash | bashto upload coverage. The job's environment variables, including deploy credentials, were in that process's reach. Codecov built its Docker images in a way that left a cloud storage credential inside an image layer. What went wrong. Someone extracted that credential, used it to edit the hosted script, and added one line that posted the output ofenvand the git remote URL to an outside server. The change sat there from late January to April 2021, until a customer compared the script's checksum with the published one and saw a mismatch. Why it's bad. Every pipeline that ran the script handed its secrets to the attacker, with nothing on the pipeline side changing. The exfiltration was an ordinary HTTPS POST from a job that was allowed to talk to the internet. The lesson. For this design: a per-job egress allowlist would have turned the POST into a logged deny, since the attacker's host was not a registry or a Codecov domain; short-lived OIDC credentials would have made the stolenenvworthless an hour later; and the platform should make checksum-verified fetches easy so customers stop piping the internet into bash.
32–48 min: Deep dives, ranked by blast radius
Pick the order by what a compromise costs. Kernel escape first (T1, T2), then identity (T4, T6, T8), then egress (T5), then caches (T7), then the human paths (break-glass) and the proof (provenance).
1. Isolation tiers per hardware class
Decision tableSay the row, then the reason.
| Hardware | Sandbox | Why this one | What it costs |
|---|---|---|---|
| Linux x86-64 (EPYC) | Firecracker microVM per job (via Kata or directly), optionally gVisor inside for docker build steps | Own guest kernel: a kernel bug in the job costs the guest, not the host. Boots in under a second from a prepared rootfs | ~5–10 % CPU, more for I/O-heavy builds; no GPU passthrough by default |
| Linux arm64 (AmpereOne) | Same, KVM on arm64 | Identical model; arm64 KVM is mature. One set of operational tooling for both racks | Some ecosystems ship fewer arm64 binaries, so builds fetch more sources |
| macOS (Apple Silicon) | One vz guest per job, from a template; at most two guests per host | The only supported way to run macOS in isolation. The VM is the boundary; there is no container story for macOS | Capacity: two jobs per machine. Boot 20–60 s, so pre-warm; a pooled VM must be reset to template between jobs, never reused |
| Cloud burst (AWS/GCP) | Bare-metal or nested-virt instances running the same microVM stack | Keep one isolation model everywhere so policy and tooling don't fork | Cost; cloud IMDS becomes a new target (T5) |
| Premium tenants | Dedicated hosts, same stack | Removes T2 side channels entirely for the tenants who pay for it | Lower utilisation |
Why not plain containers: a container shares the host kernel, so a job's syscalls hit the same code that protects every other tenant. Why not gVisor alone: it narrows the host kernel surface dramatically but is still one Go process on the host; an escape from the Sentry is an escape to the host. Why gVisor inside the VM for Docker builds: BuildKit's own history of escapes (the January 2024 set, CVE-2024-23651 and CVE-2024-23653, and runc's CVE-2024-21626) means the build step is the most likely thing to break out of its container; giving it a second wall inside a sacrificial VM makes that a nuisance rather than an incident. The sandbox page covers the mechanics; here you say the decision and the reason.
In practiceWhat the host agent enforces at VM creation, abridged from a Firecracker config the controller generates:
{
"boot-source": { "kernel_image_path": "/images/vmlinux-6.12-hardened", "boot_args": "console=ttyS0 reboot=k panic=1 pci=off init=/sbin/runner-init" },
"drives": [
{ "drive_id": "rootfs", "path_on_host": "/images/runner-linux-2026.10.3.ext4", "is_root_device": true, "is_read_only": true }, ← golden image, read-only
{ "drive_id": "scratch", "path_on_host": "/var/lib/jobs/j-8f3a/scratch.ext4", "is_root_device": false, "is_read_only": false } ← per-job, discarded after
],
"network-interfaces": [ { "iface_id": "eth0", "host_dev_name": "tap-j8f3a", "guest_mac": "02:00:00:8f:3a:01" } ], ← one tap per job; host routes it only to the proxy
"machine-config": { "vcpu_count": 4, "mem_size_mib": 8192, "smt": false }, ← no sibling-thread sharing with another tenant
"mmds-config": { "version": "V2", "network_interfaces": ["eth0"] } ← job config via MMDS, not a cloud IMDS
}2. Image builds without a privileged daemon
What is this?Customers run docker build. The naive approach is Docker-in-Docker: a privileged container running dockerd. Privileged means all capabilities, no seccomp, device access: a container escape is a configuration, not an exploit.
How it works insteadRootless BuildKit: buildkitd runs as an unprivileged user inside a user namespace (the kernel feature that maps "root" inside to an ordinary UID outside), using rootlesskit for its own mount and network namespaces. Builds are launched daemonless with buildctl talking over a Unix socket that only this job can see. Inside a microVM this is belt and braces: even a full rootless-BuildKit escape lands you as a non-root user in a guest that is destroyed in minutes.
# inside the job VM: what "docker build" is actually running
$ ps -o user,pid,cmd -u builder | head
USER PID CMD
builder 1193 rootlesskit --net=slirp4netns --disable-host-loopback buildkitd --oci-worker-no-process-sandbox ← not root; no host loopback, so no reaching host services
builder 1201 buildkitd ...
$ cat /proc/1201/uid_map
0 1000 65536 ← "root" in the build namespace is UID 1000 outside
$ buildctl build --frontend dockerfile.v0 --local context=. --local dockerfile=. \
--opt network=none \ ← RUN steps get no network; dependencies come from the mirror in a fetch stage
--export-cache type=s3,bucket=cache-tenant-acme,prefix=repo-42/trusted/ \ ← tenant- and trust-scoped cache
--output type=image,name=registry.internal/acme/app:sha-e1b2,push=trueCache storage for BuildKit layers is per tenant and keyed by the trust class of the build (more below); a tenant's rootless BuildKit never mounts another tenant's cache directory because the directory does not exist inside its VM.
3. Runner registration tokens: the complete lifecycle
What is this?A runner has to prove to GitHub or Buildkite that it is allowed to take jobs. The long-lived way is a registration token or agent token in a config file, which, on a multi-tenant platform, means every job can read a credential that registers new runners.
How it worksBoth services support an ephemeral model; the control plane owns the credential, the job never sees it.
-
GitHub: controller calls generate-jitconfigserverper job
POST /repos/{o}/{r}/actions/runners/generate-jitconfigwith the app installation token; returns an encoded config for ONE runner, ONE job- Runner starts with
--jitconfig; it is ephemeral by construction and is removed when the job completes
-
Buildkite: controller keeps the cluster agent tokenservernever in the VM
- Stack controller holds the cluster token; agent in the VM starts with
--acquire-job <id>using a short-lived job acquisition token, and--disconnect-after-job - Agent → Buildkite: receives a session token for its connection, then a job token that expires when the job ends
- Stack controller holds the cluster token; agent in the VM starts with
-
Inside the job: only job-scoped credentials existtlsGITHUB_TOKEN / job token, OIDC on request
- Both are useless after completion and tied to this job's identity
-
After the job: controller revokes and verifiesdefendno re-registration possible
- GitHub JIT runner is gone; Buildkite agent has disconnected; the controller confirms the sandbox is destroyed before releasing capacity
Zero cross-job state remanence is a property you verify, not assume: the VM's scratch disk is a fresh file per job, deleted after; memory is freed by the VMM's exit; the host agent runs a post-job check that the tap device, the scratch file, the per-job proxy policy and any cgroup are gone, and refuses to schedule onto a slot that fails the check. For pooled macOS guests, "destroy" means revert to the template image and rotate the guest's machine identity; a guest that cannot be reverted is retired, not reused.
4. Workload identity instead of stored secrets
What is this?A customer's job needs to deploy to their AWS account. The old way is an access key in CI secrets, readable by any job in the repository, and by any dependency of any job. The OIDC way is that GitHub or Buildkite issues the job a signed token describing what it is, and the customer's cloud exchanges that for short-lived credentials if the claims match a trust policy.
How it works
- iss
- The issuer:
https://token.actions.githubusercontent.comor the Buildkite agent OIDC issuer. The verifier fetches its JWKS and checks the signature - aud
- The audience the customer's role expects, e.g.
sts.amazonaws.com. Rejected if it does not match - sub
- The subject: for GitHub
repo:acme/app:environment:prodorrepo:acme/app:ref:refs/heads/main; for Buildkiteorganization:acme:pipeline:app:ref:refs/heads/main:… - repository_id / repository_owner_id
- Immutable numeric IDs; a renamed or transferred repo keeps them, a re-created one does not. GitHub is moving new repositories to a subject format that includes these
- job_workflow_ref
- Which workflow file, at which ref, is running: lets a policy trust only a vetted reusable workflow
- runner_environment
github-hostedorself-hosted; a customer may refuse tokens from runners they do not control- exp / iat
- Minutes, not days. A stolen token is a short-lived problem
The two mistakes to call out: a trust policy with StringLike on repo:acme/* lets any repository in the org, including a fork's pull_request workflow if the policy does not pin ref or environment, assume the production role; and a policy keyed on the repository name survives a delete-and-recreate by someone else with the same name, which is why you pin the numeric IDs. Platform-side, the job's own OIDC token is requested by the job, so the platform's job is to make sure only this job's VM can request this job's token: the runner's job token lives in the VM, the VM is single-tenant, and it is gone in minutes.
In practiceThe trust policy you want to see in a customer's account, and the one you don't:
{ "Condition": {
"StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:repository_id": "812345", ← immutable id, not the name
"token.actions.githubusercontent.com:environment": "prod" }, ← only the protected environment
"StringLike": { "token.actions.githubusercontent.com:sub": "repo:acme/app:environment:prod" } } }
{ "Condition": { "StringLike": { "token.actions.githubusercontent.com:sub": "repo:acme/*" } } } ← any repo, any branch, any fork's PR workflow: do not ship this5. Egress: default deny, with a way to say yes
What is this?Jobs need registry.npmjs.org; they must not reach 169.254.169.254, 10.0.0.0/8, the scheduler, or a random server that a malicious dependency wants to talk to.
How it worksThree layers, each catching what the previous misses:
-
1. Host datapath: the job's tap can only reach the proxynetworknftables or eBPF on the tap/cgroup
- Drop link-local (169.254.0.0/16), RFC 1918, the control-plane prefixes, and other tenants' segments; allow only the proxy IP
-
2. Policy proxy (Go): per-job allowlist by host namedefendCONNECT-level control
- Default list: package registries, GitHub, the tenant's mirrors. Tenant-supplied additions reviewed as config, not accepted from the job
- Logs every CONNECT with job id, tenant, destination, bytes; denies are alerts when they repeat
-
3. Dedicated egress IP pools per tenanttlsfor the customer's side of the wall
- Tenant Acme's jobs leave via Acme's pool, so Acme can allowlist the platform in their own firewalls without allowlisting every other customer
Why IP allowlists alone fail: registries sit behind CDNs whose addresses change hourly, so an IP list is either stale or so broad it includes the attacker's bucket on the same CDN. Why DNS-based allowlists alone fail: the job controls its own resolver and can answer "registry.npmjs.org" with any address, so the enforcement point has to be the proxy that does the lookup itself, or a datapath that pins the IP the trusted resolver returned. Why the cloud metadata service deserves its own line: a job in a cloud-burst VM that can reach IMDS gets the instance's role; the fix is both no meaningful role on the instance and a drop rule on the guest's tap, plus IMDSv2 with a hop limit of 1 so a response cannot be routed past the host.
In practiceThe proxy log is one of your best detection sources:
2026-10-24T10:02:11Z tenant=acme job=j-8f3a ALLOW CONNECT registry.npmjs.org:443 bytes_out=4.1k bytes_in=38.2M
2026-10-24T10:02:40Z tenant=acme job=j-8f3a ALLOW CONNECT github.com:443 bytes_out=2.0k bytes_in=1.1M
2026-10-24T10:03:02Z tenant=acme job=j-8f3a DENY CONNECT 203.0.113.44:443 reason=not-allowlisted ← a postinstall script phoning home
2026-10-24T10:03:02Z tenant=acme job=j-8f3a DENY CONNECT 169.254.169.254:80 reason=link-local ← someone is probing for cloud credentials6. Cache partitioning and key integrity
What is this?Caches make the platform fast and are the easiest place to smuggle state from one job to another. A pull-request build that can write to a cache a release build will restore has turned a comment into code execution on the trusted branch.
How it works
tenant/repo/trusted/… is written only by builds of protected branches; tenant/repo/untrusted/… by everything else. Untrusted builds may read trusted entries (fast PRs) but never the reverse. This mirrors GitHub's own rule that a base branch cannot restore a cache created by a PR branch.
An entry is stored under the hash of its bytes; the mapping from cache key to entry hash is a manifest the controller signs. A restore verifies the manifest signature and the content hash, so an attacker who can write to the bucket but not to the controller can only cause misses.
Nothing is executed on restore; a cache is files, and a poisoned node_modules is still just files until the job runs it, which is the job's own risk inside its own VM.
The job gets a token for exactly its namespace and trust class, minted by the broker, expiring with the job.
cache key: v3-npm-{{ hashFiles('package-lock.json') }}-linux-arm64
namespace: s3://ci-cache-acme/repo-42/trusted/ ← writable only by main/release builds
s3://ci-cache-acme/repo-42/untrusted/ ← PR builds write here, read both
manifest: { "key": "v3-npm-9c1e…-linux-arm64", "blob": "sha256:7d20…", "writer_job": "j-77c1", "trust": "trusted", "sig": "controller-2026-10:…" }
restore: verify sig → fetch blob by hash → verify sha256 → unpack ← any mismatch is a miss plus an alert, never a partial restore7. Break-glass: debugging a job without exposing the node
What is this?A developer's job is stuck and they want a shell. On a platform the interactive path is the most dangerous feature you ship: it is a human, with a real account, inside the sandbox, often with more patience than the job had.
How it worksThe shell goes into the sandbox and nowhere else:
- An
sshdstarts inside the job's VM only when debugging is requested, listening on the job's private segment, reached through the proxy tier with the same per-job identity. - Authentication is an SSH certificate minted by the controller for this user, this job and a 30-minute window, with
force-commandandsource-addresscritical options, after the CI service confirmed the user has write access to the repository. Noauthorized_keysfiles on any image. - The session user is a unique OS account created for the session, not the runner user, so file ownership attributes what the human did versus what the job did.
- Session recording is done by the controller-side proxy and, inside the VM, by an eBPF
execvetracer that logs commands even if the user disables terminal echo. Logs go to the write-only sink. - The VM's lifetime extends to the debug window and then it is destroyed like any other; there is no "keep it around" option.
Because the sshd is inside the VM, a compromised debug session has exactly the reach the job had. It cannot see the host, the co-located jobs or the control plane, because none of them are reachable from that network.
8. Observability and provenance you can prove
What is this?Two audiences need proof: you, when something goes wrong, and customers, when they ask whether an artifact really came from their source on your platform.
How it works
The host agent exports VM lifecycle events, tap-level flow summaries and eBPF process-execution traces from the host side of the boundary (and from an agent inside the golden image the job cannot remove without ending the job). Logs are shipped to a sink the job network cannot reach.
The controller, not the job, writes the SLSA provenance: source digest from the checkout it performed, the golden-image digest, the resolved dependency log from the proxy, the job's identity. It signs keylessly with its own workload identity. That is the SLSA Build L3 separation: the signing identity is never inside a user-defined step.
Baseboard management controllers (BMCs) live on an isolated management network; hosts boot measured, from an image rebuilt reproducibly; a host that fails attestation is drained. Hardened C or shell wrappers that run as root on the host are kept tiny, reviewed, and pinned by digest like any other dependency.
In practiceThe provenance a customer can verify, abridged:
{ "predicateType": "https://slsa.dev/provenance/v1",
"subject": [{ "name": "registry.internal/acme/app", "digest": { "sha256": "7a1f…e3" } }],
"predicate": {
"buildDefinition": {
"buildType": "https://ci.example.com/buildtypes/microvm-job/v1",
"externalParameters": { "repository": "https://github.com/acme/app", "ref": "refs/tags/v1.4.2", "workflow": ".github/workflows/release.yml" },
"resolvedDependencies": [
{ "uri": "git+https://github.com/acme/app@refs/tags/v1.4.2", "digest": { "gitCommit": "e1b2…9f" } }, ← what the controller checked out
{ "uri": "oci://images.internal/runner-linux", "digest": { "sha256": "c3d4…10" } }, ← golden image the job ran in
{ "uri": "https://registry.npmjs.org/", "annotations": { "fetched_via": "egress-proxy", "entries": 412 } } ] },
"runDetails": { "builder": { "id": "https://ci.example.com/controller/v3" }, ← the controller, not the job
"metadata": { "invocationId": "j-8f3a", "startedOn": "2026-10-24T10:01:58Z" } } } }48–55 min: Trade-offs and failure modes
Say these unprompted; they are where senior candidates separate from mid-level ones.
If the proxy tier is down, jobs cannot fetch dependencies and the platform is effectively down. That is correct: fail-open egress is how one outage becomes a breach. Mitigate with a proxy per rack and health-based scheduling, not with a bypass flag.
MicroVMs cost a few percent of CPU and more on I/O; gVisor inside them costs more on syscall-heavy steps. Offer dedicated hosts and larger VMs rather than weaker isolation. The one place you compromise is the trusted tier: your own platform builds on protected branches can run with gVisor-only if the latency matters, because the threat is lower, and you say so explicitly.
Two guests per host means macOS is scheduled, not elastic. Queue depth per tenant, fair-share, and honest SLAs beat heroic reuse of guests.
Every rule that makes caches safer (trust classes, signed manifests, per-job credentials) makes hit rates lower. Measure the hit rate; sell the trusted-branch warm cache as the fix for slow PRs.
for the platform's own integrity, which is why the control plane is small, written in a memory-safe language, has no inbound path from the data plane, and why provenance is designed so a customer could detect a lying controller by rebuilding.
Per-VM CPU and memory limits, I/O throttles and SMT policy stop one tenant from starving or side-channelling another; they also cap the blast radius of a crypto-miner job, which is the most common abuse you will actually see.
IMDS, cloud IAM on instances and the provider's shared-responsibility line all appear; keep burst capacity running the same microVM stack and give the instances no useful role.
🎯On the job. The first incident on a platform like this is rarely an escape. It is a job that mines, a job that scans RFC 1918, or a token in a log. Build the proxy logs, the per-job flow summaries and the token-usage telemetry before the fancy sandboxing is finished; they catch the common case and tell you when the rare one happens.
55–60 min: Summary and what you would build first
In practiceThe closing you want to deliver:
"Three invariants: a job can reach only the proxy and its allowlist; a job keeps nothing, because the VM, disk,
tap, token and cache credential are all per-job and destroyed together; and a job can pretend to be nothing
but itself, because registration is single-use, cloud access is OIDC pinned to immutable ids, and provenance
is signed by the controller rather than the job.
Order of build: 1) microVM-per-job with immutable hosts and the host agent's destroy check, 2) egress proxy
with default deny and logging, 3) JIT registration and the token broker, 4) partitioned signed caches,
5) break-glass via in-VM sshd with certificates, 6) controller-signed provenance.
If I could only ship one thing this quarter it would be 1 and 2 together: they bound reach and keep,
and everything else is refinement."Then ask your question: "What does your current break-glass path look like, and who can use it?" It shows you know where the human risk lives.
Interview Q&A
One job, one virtual machine, everywhere. On the EPYC and AmpereOne racks that is a Firecracker microVM per job under KVM, booted from a read-only golden image with a per-job scratch disk and a per-job tap device, so a kernel bug in the job costs the guest kernel, not the host. On Apple Silicon it is one guest under Apple's Virtualization framework per job, at most two per machine, pre-warmed and reverted to a template between jobs because boot is tens of seconds. For Docker builds I add gVisor inside the Linux VM so the step most likely to break out of its container hits a second wall. Plain containers share the host kernel with every other tenant, so they are not a tenant boundary here, and I would also disable sibling-thread sharing between tenants and offer dedicated hosts for customers who need side channels off the table.
Docker-in-Docker means a privileged container running a root daemon: all capabilities, no seccomp, device access, and a socket that is effectively root on the host. An escape from that is configuration, not exploitation, and BuildKit and runc have had real escapes, the January 2024 BuildKit set and runc's CVE-2024-21626 among them. Rootless BuildKit runs buildkitd as an ordinary user inside a user namespace, so root inside the build maps to an unprivileged UID outside, with rootlesskit giving it its own mount and network namespaces and no host loopback. Builds run daemonless over a per-job socket. I still put it inside a per-job microVM, so a complete rootless-BuildKit escape yields an unprivileged user in a guest that is destroyed when the job ends, and the build-cache directories of other tenants simply do not exist in that guest.
Three layers. On the host, the job's tap device can reach only the policy proxy; link-local including 169.254.169.254, RFC 1918, the control-plane prefixes and other tenants' segments are dropped at the datapath. The proxy enforces a per-job host-name allowlist at the CONNECT level, doing its own resolution so a job that lies to its resolver gains nothing, with registries and the tenant's mirrors allowed by default and tenant additions treated as reviewed config. Each tenant's jobs leave through a dedicated egress IP pool so customers can allowlist the platform without allowlisting everyone. Speed comes from local mirrors and warm trusted caches, not from opening the network. In cloud burst I also run IMDSv2 with a hop limit of one and give instances no meaningful role, and every deny in the proxy log is a detection, which is how Codecov-style exfiltration would have shown up.
The credential that registers runners never enters the job. For GitHub the controller calls generate-jitconfig with its app token and gets a config valid for one runner and one job; the runner boots with it, is ephemeral by construction and is removed on completion. For Buildkite the controller keeps the cluster agent token, starts the in-VM agent with a short-lived job acquisition token for a specific job and disconnect-after-job, and the agent receives a session token and then a job token that expires with the job. Inside the VM only job-scoped credentials exist. Remanence is verified, not assumed: the scratch disk is a per-job file that is deleted, the tap and per-job proxy policy are torn down, and the host agent runs a post-job check that all of them are gone before it releases the slot; a pooled macOS guest is reverted to its template and gets a new machine identity, or is retired.
Start sshd inside the job's own VM, on demand, reached through the same proxy tier with the job's identity, so the shell has exactly the job's reach and nothing more. Authenticate with an SSH certificate the controller mints for this user and this job with a short validity and source-address and force-command options, after the CI service confirms the user has write access to the repository; no authorized_keys anywhere. Create a unique OS account for the session so what the human did is attributable separately from what the job did. Record the session at the proxy and with an eBPF exec tracer inside the VM, shipping to a sink the job cannot reach, and let the VM live only until the debug window closes, then destroy it like any other.
The job asks its CI service for a signed OIDC token that describes it, and the customer's cloud exchanges it for short-lived credentials if the claims match a trust policy. The verifier checks the issuer's signature via its JWKS, the audience, expiry, and then the identity claims: the subject, which encodes repository and either ref or environment, plus the immutable repository and owner IDs, and ideally the job workflow reference so only a vetted reusable workflow qualifies. The two mistakes are a wildcard subject like repo:acme/star, which lets any repository or a fork's pull request assume a production role, and matching on the repository name, which a delete-and-recreate inherits; pin the numeric IDs and the environment. The platform's part is making sure only that job's VM can request that job's token, which single-tenant VMs and job-scoped runner tokens give you.
Partition by tenant, then repository, then trust class: protected-branch builds write to a trusted namespace, everything else to an untrusted one, and reads flow only from trusted to untrusted, never back, the same rule GitHub applies between base and PR branches. Store entries content-addressed by their hash and have the controller sign the manifest that maps keys to hashes, so an attacker who can write to the bucket can only cause misses. Mint a per-job cache credential scoped to that namespace and trust class, expiring with the job. Treat restored content as inert data, verify signature and hash before unpacking, and alert on mismatches rather than partially restoring. The cost is a lower hit rate for PRs, which you recover with a warm trusted cache they are allowed to read.
Keep the default-deny datapath and add a tenant-specific route that terminates in their network, not in ours. Concretely, a per-tenant WireGuard or cloud private-link tunnel from the tenant's dedicated egress pool to their VPC, so only that tenant's jobs can enter it, with the proxy's allowlist for that tenant extended by the private host names the customer declares in reviewed config. The tunnel endpoint runs in the egress tier, never on job hosts, so a job still cannot see any tunnel but its own tenant's. I would require the customer to authorise the egress pool on their side and recommend they treat our platform as an untrusted network with its own segment, because from their perspective our runner is a stranger's code with a route into their VPC.