Security Notes
System Design

System Design: Hardening and Architecting a Secure Multi-Tenant CI Runner Platform

33 min read 13 sections 8 model answers verified 2026-10 version 1 · updated 2026-10-24

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.

Important

The 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

Runner

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.

Job

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.

Tenant

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.

Sandbox

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.

Control plane

The part you trust: the API, the scheduler, the token broker, the provisioner. It never runs customer code.

Data plane

The part that runs customer code: the hosts and the sandboxes on them. Assume any one sandbox is compromised at any time.

Egress

Network traffic leaving a job. Jobs need package registries; they must not reach your internal network, the cloud metadata service or other tenants.

Workload identity

A short-lived, verifiable identity for a job (an OIDC token) instead of a long-lived secret pasted into settings.

Memory hook

reach, 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

  1. 0–5 min · Restate and clarifybrowserdon't design yet
    • One-sentence restatement; four questions whose answers change the architecture
  2. 5–12 min · Requirements, assumptions, numbersdnsscope the thing
    • Tenancy model, hardware tiers, job shape, scale, what "safe" means here
  3. 12–22 min · Baseline architectureserverdraw control plane vs data plane
    • Job lifecycle end to end, before any hardening
  4. 22–32 min · Threat modelattackwho attacks whom, through what
    • Job → host, job → other tenant, job → your internals, job → cloud, you → customer
  5. 32–48 min · Deep dives where it mattersdefendranked by blast radius
    • Isolation tiers, image builds, tokens, identity, egress, caches, break-glass, provenance
  6. 48–55 min · Trade-offs and failure modesrenderfail-closed vs fail-open, cost, latency
  7. 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:

text
"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:

text
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 s

The 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. 1. EventserverGitHub workflow_job.queued webhook, Buildkite job via cluster queue, or own API call
  2. 2. Scheduleserverpick a host with capacity for the job's platform and tenant policy
  3. 3. Mint identitytlssingle-use runner registration, job-scoped OIDC audience, cache credentials
  4. 4. Provisionoshost agent boots a sandbox from a golden image with the config injected
  5. 5. Runattackthe runner inside registers, pulls the job, executes customer steps as root inside the sandbox
  6. 6. Egressnetworkall traffic leaves via the policy proxy
  7. 7. Collectserverlogs streamed out, artifacts and cache entries written to tenant storage
  8. 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 → targetMechanismPrimary controlSecondary control
T1Job → host kernelKernel bug reachable through syscalls; runc/BuildKit escapeHardware virtualisation (microVM / vz) so the guest kernel is sacrificialgVisor inside the VM for defence in depth on Linux; minimal host OS; SMT scheduling per tenant
T2Job → other tenant on same hostSide channels; shared caches; shared network segmentPer-job VM, per-tenant network segment, per-tenant cache namespacesDedicated hosts for premium tenants; no two tenants on sibling hyperthreads
T3Job → host persistenceWriting to host disk, firmware, BMCImmutable host image, ephemeral VM disks, no job-writable host pathsMeasured boot, periodic host re-image, BMC on an isolated management network
T4Job → your control planeNetwork route to scheduler/API; stolen host-agent credentialDefault-deny egress; control plane reachable only from host agents over mTLS, one-wayHost-agent credential bound to that host's TPM; per-host authorisation
T5Job → cloud metadata / cloud accountIMDS at 169.254.169.254 from a cloud-hosted sandboxBlock link-local from guests; IMDSv2 with hop limit 1; no instance role with real permissionsAlert on any IMDS hit from the job network
T6Job → customer's cloud (over-scoped OIDC)Token's sub matches a broad trust policyCustomer guidance plus audience and claim pinning; immutable repository IDs in claimsPlatform-side telemetry on which audiences jobs request
T7Job A → Job B via cachePoisoned dependency layer restored by a trusted buildCache keys include tenant, repo and trust class; PR caches never readable by base buildsContent-addressed entries; signed manifests; cache is data, never executed on restore
T8Job → credential theft from runner processDumping runner memory, reading envSecrets fetched per step and held briefly; sandbox is single-tenant so theft is self-theftShort token lifetimes; outbound beacon detection at the proxy
T9Platform → customer (your own insiders, your compromise)Reading job contents, altering artifactsProvenance signed by a controller, logged outside operator reach; break-glass access recordedCustomer-verifiable attestations; separation of duties for golden images
T10Supply chain → platformMalicious base image, runner binary, actionPinned digests for everything the platform itself runs; reproducible golden imagesRebuild 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 | bash to 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 of env and 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 stolen env worthless 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.

HardwareSandboxWhy this oneWhat it costs
Linux x86-64 (EPYC)Firecracker microVM per job (via Kata or directly), optionally gVisor inside for docker build stepsOwn 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 arm64Identical model; arm64 KVM is mature. One set of operational tooling for both racksSome 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 hostThe only supported way to run macOS in isolation. The VM is the boundary; there is no container story for macOSCapacity: 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 stackKeep one isolation model everywhere so policy and tooling don't forkCost; cloud IMDS becomes a new target (T5)
Premium tenantsDedicated hosts, same stackRemoves T2 side channels entirely for the tenants who pay for itLower 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:

json
{
  "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.

text
# 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=true

Cache 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.

  1. GitHub: controller calls generate-jitconfigserverper job
    • POST /repos/{o}/{r}/actions/runners/generate-jitconfig with 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
  2. 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
  3. 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
  4. 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

issstring
audstring
substring
repository_id / repository_owner_idstring
job_workflow_refstring
runner_environmentstring
exp / iatint
iss
The issuer: https://token.actions.githubusercontent.com or 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:prod or repo:acme/app:ref:refs/heads/main; for Buildkite organization: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-hosted or self-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:

json
{ "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 this

5. 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. 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. 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. 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:

text
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 credentials

6. 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

Namespace by tenant, then by repository, then by trust class.

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.

Content-address the entries and sign the index.

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.

Treat restored content as data.

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.

Scope cache credentials per job.

The job gets a token for exactly its namespace and trust class, minted by the broker, expiring with the job.

text
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 restore

7. 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 sshd starts 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-command and source-address critical options, after the CI service confirmed the user has write access to the repository. No authorized_keys files 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 execve tracer 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

Host telemetry outside the job's reach.

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.

Provenance signed by the controller.

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.

Racks are infrastructure, not an afterthought.

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:

json
{ "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.

Fail closed, and know what that costs.

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.

Isolation vs speed.

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.

macOS capacity.

Two guests per host means macOS is scheduled, not elastic. Queue depth per tenant, fair-share, and honest SLAs beat heroic reuse of guests.

Caches are a performance feature with a security budget.

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.

Control-plane compromise is game over

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.

Noisy neighbours are an availability threat with a security edge.

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.

Cloud burst changes the threat model.

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:

text
"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

Q
How do you enforce physical or virtualisation-grade boundaries between untrusted CI jobs across x86-64, arm64 and Apple Silicon hardware?
Model answer

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.

Q
Why is standard Docker-in-Docker unacceptable for multi-tenant CI, and how does rootless BuildKit with user namespaces mitigate container escape?
Model answer

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.

Q
How do you design network egress controls to prevent CI jobs from hitting cloud metadata services or pivoting to private networks while keeping package mirrors fast?
Model answer

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.

Q
What is the complete lifecycle of an ephemeral runner registration token in GitHub Actions or Buildkite, and how do you guarantee zero cross-job state remanence?
Model answer

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.

Q
How would you implement secure CI job debugging using ephemeral sshd without exposing the node or co-located jobs?
Model answer

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.

Q
How do you replace hard-coded cloud credentials in CI with OIDC workload identity, and how are claims like repository, environment and ref validated?
Model answer

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.

Q
How do you design build-cache partitioning and cache-key integrity to prevent an attacker from poisoning shared dependency layers across repositories or branches?
Model answer

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.

Q
The interviewer says: "A customer insists their jobs must reach their private VPC. Design that without breaking tenant isolation."
Model answer

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.