ADR-170: Worker Threat Model and Security Principles
Status: Accepted Date: 2026-05-07 Tracker: warp#1881 (Phase 0 of EPIC warp#1838) Cross-refs: ADR-111 (broker as the only model-call layer), ADR-118 (per-block validation), ADR-124 (block-composition principle), ADR-166 (receiver/executor split — sibling pattern), ADR-163 (all CI on our runners — prerequisite)
Context
The fleet's unattended work runs as Claude Code sessions on the operator's laptop. The substrate paper (docs/proposals/unattended-pipeline-execution-substrate.md) proposes moving that work to dedicated worker hosts. The naive shape — "put claude -p in a container with all the team's credentials" — is unacceptable: a compromised container would leak the Anthropic API key, full source code, GitHub PATs, Cloudflare tokens, and the operator's credential set in one go. The blast radius makes the move worse than staying on the laptop.
This ADR commits to a security posture for unattended workers that makes them useless on compromise. The dominant threat is worker compromise leading to secret theft and intellectual-property exfiltration. Attack vectors include supply-chain compromise (malicious dependency at build or run time), the work itself (a git-ref input whose tree contains malicious code that runs during npm install / go test), prompt injection (model-call input subverts the model into invoking tools out of spec), container escape, image tampering, fellow-tenant compromise on shared hosts.
The structural answer: layer the defences so what an attacker can extract is small, narrowly-scoped, short-lived, and instantly revocable.
Decision
Every unattended worker — every player, executor, runner agent — MUST satisfy the four-layer security model below. Cutting any layer collapses the others' guarantees.
Layer 1 — Useless-on-compromise credentials
The worker holds only credentials that are useless beyond the current job:
- Anthropic API key. Never on a worker. Workers call
model-callblocks → HTTPS to the broker (ADR-111). The worker holds a broker token scoped to "this caller-id, these models, this rate limit, this cost ceiling, expires in N hours". Broker revokes instantly on suspicion. - GitHub access. Never a long-lived PAT. A GitHub App installation token is issued just-in-time, scoped to specific repos and permissions, valid for the pipeline's
timeout + grace. Write tokens (forgh-merge-class blocks) are issued at the start of the merge step and revoked when it ends. - Warp access. Per-claim Warp token, scoped to "heartbeat and complete this one item-id".
- Object store access. Pre-signed URLs for the specific input/output paths of the current job, valid only for the job's duration.
- Vault access. Workers hold a vault session token good for one job, scoped to the credentials the pipeline declared.
No actor holds a credential more powerful than the one it needs for the immediate next operation.
Layer 2 — Hardened containers
- Distroless base image. No shell, no package manager, no debug tools.
- Non-root user. All blocks run unprivileged.
- Read-only root filesystem. Writes go only to declared mount points (a tmpfs scratch dir).
- All Linux capabilities dropped except those the player explicitly needs (typically none). Seccomp profile restricts syscalls to the minimum.
- Network egress allowlisted by the host, not the container. Worker reaches only: broker, Warp, GitHub (api + git over https), one object-store endpoint. A compromised container cannot rewrite its own routing table to bypass.
- One-shot containers. Fresh container per claim, destroyed on completion. No persistence; no lateral movement.
- Reproducibly-built, signed images. Runtime verifies signature on launch.
- No SSH, no debug daemons, no inbound network listening. Worker is initiator-only.
Layer 3 — Scoped tool surfaces
- Default
claude -p --allowed-tools="". Themodel-callblock's default is no tools. Adversarial input becomes a string-out problem; output is validated against the block'soutputs:schema before flowing onward. - When tools are needed, scope them per-block. Read-only tools for read-only work. Tool calls are first-class observable events, streamed to the audit log.
- Prompt-corpus regression. Per ADR-109, every prompt version ships with a regression corpus including adversarial-input pairs.
Layer 4 — Observability and revocation
- Live event stream per worker — block start/end, model-call counts (not content), tool invocations, egress attempts. Surfaced in
/operate. - Anomaly auto-detection — token spend / time deviation from baseline auto-pauses the worker.
- Honey-secrets — fake credentials planted on each worker; any sighting in any audit channel triggers immediate revocation and quarantine.
- Per-worker token revocation in seconds across broker, Warp, GitHub App, vault.
- Audit trail retention — every block invocation's structured trace
(block-id, input-hash, output-hash, exit-code, duration)retained ≥ 90 days.
Consequences
What becomes load-bearing
The four layers together make worker compromise survivable:
- Stolen credential exfiltration → bounded. The worst case is a few hours of model spend and one job's source-tip, all auto-revoked within minutes.
- Persistence → impossible. One-shot containers + tmpfs scratch + read-only root = nowhere to land.
- Lateral movement → impossible. Egress allowlist + per-host firewall + initiator-only network shape = no outbound to anywhere else.
- Prompt-injection → bounded to typed-output corruption. No tool surface = no way for adversarial input to drive side effects.
What stays out of scope
- Non-worker components — operator's laptop (interactive
claude -pwith full agent tools is fine on the laptop; the trust boundary is already there), broker host (its own threat model; ADR-111), Warp host (its own threat model). - Prevention of every CVE in container runtimes — defence in depth, not infallibility. The audit trail catches what the layers miss.
- Hardening of existing fleet hosts that aren't running the substrate yet — current runner-pool hosts (warp#1721) inherit the substrate's hardening when the substrate ships, not before.
What must change in process
- Every PR that adds a new block declares its
scope(credentials, network, filesystem, syscalls). Persona reviewer (ADR-115) checks the declaration against what the block actually does. - Every PR that bumps a
model-call-using block's prompt ships an adversarial-input corpus update in the same diff. - Every worker host's image is built reproducibly and signed; runtime verifies before launch. Image-build workflows go through ADR-163's self-hosted-only contract.
- The broker enforces the per-pipeline budget; pipelines that exceed it fail fast.
Compliance / how reviewers check
A PR introducing a new pipeline or block satisfies this ADR iff:
- Its
scope.credentialslists only the credentials the work needs; each credential has a TTL ≤ pipeline timeout + grace. - Its
scope.network.egress_allowlistis enumerated; no wildcards. - Its
scope.filesystem.workspaceistmpfsandpersistence: false(or the deviation is justified in the PR body). - The block's runtime is
go-binarywithsyscalls: minimal(or the deviation is justified). - For
model-callblocks: a regression corpus exists atlibrary/prompts/<name>.regressions/and was re-run in this PR.
A PR that violates any of the four layers is rejected at structural-checks time. The honey-secret detection and per-token revocation are operationalised by warp#1838 Phase 6 (container hardening) and Phase 8 (observability surface) — those phases must ship before this ADR's full guarantees hold; ADR is published now to lock the design before code lands.
Cross-link
The substrate paper Part IV walks through each layer with the per-line attribution to historical incidents and CVEs that motivate it. ADR-166's executor-isolation pattern is this ADR's smaller-scope proving ground (the executor is a worker; deploys are pipelines). ADR-163's self-hosted-only commitment is the prerequisite — without our runners, there is no useless-on-compromise property to enforce.