- Status: Accepted
- Date: 2026-07-14
- Deciders: thulasi.ram (architect)
- Builds on: 0037 (Environment = admin-owned governed bundle), 0038 (third-party reuse = OCI image), 0008 (OCI image = the step), 0005 (soft tenancy), 0018 (rootless BuildKit)
- Depends-later-on: 0015 (provenance/cosign — deferred; not required to ship this)
Context
Section titled “Context”0038 established that reuse across a trust boundary is an OCI image Step — isolated by its container. Some third-party images legitimately need elevated privilege (root, extra Linux capabilities, a privileged container for device access). The question is how to grant that without handing arbitrary whitelisted images a node- or cloud-account-escape on a soft-multitenant cluster (0005: namespace-per-run, gVisor/Kata deferred).
Two facts force the shape of the decision:
- There is no floor today. A normal step Pod is built with no
SecurityContextat all (scarab-executor-k8s: only the rootless BuildKit pod is hardened). You cannot whitelist “privileged” as an exception until “unprivileged” is a defined, enforced baseline. - Privilege on a shared node is a cross-tenant weapon.
privileged: truein a per-run namespace still shares the node kernel;hostNetworkreaches the node metadata endpoint and its IAM credentials. The whitelist is the only thing between “reusable deploy image” and “one repo owns the cluster/cloud account” — so it must be admin-owned, digest-pinned, fail-closed.
Decision
Section titled “Decision”-
Hardened baseline (the floor). Every step Pod runs a Kubernetes “restricted”-equivalent posture by default:
runAsNonRoot: true, drop ALL capabilities,seccompProfile: RuntimeDefault,allowPrivilegeEscalation: false, no host namespaces, no hostPath. This is what “unprivileged plugin” means; every escalation below is an explicit, named exception to it. Fail-closed. -
Privilege is a closed, Scarab-defined capability set — not a binary. A single “privileged” flag is a foot-gun (grants everything, blinds the audit). v1 vocabulary:
run-as-root—runAsNonRoot:false, uid 0. The most common baseline break; does not escape the (caps-dropped, priv-esc-off, seccomp-confined) sandbox.add-capabilities— adds specific Linux caps; the exact caps are pinned in the Environment whitelist, not in the pipeline (one grant kind, blast-radius pinned by the admin).privileged— full privileged container. The blunt “node-level power” escape hatch of last resort; loud and rare.
Explicitly out / deferred, and never folded into
privileged(they are orthogonal axes, not rungs of one ladder —privilegeddoes not include the host namespaces):cluster-access(in-cluster k8s API) — out. Deploys reach a target cluster via kubeconfig-as-a-secret (0037); Scarab’s own cluster API is off-limits to step pods.host-path— deferred (rootless BuildKit already removed the docker-socket reason;privilegedreaches it if truly needed).host-network/host-pid/host-ipc— provisionally a hard no:hostNetwork→ node metadata endpoint → node IAM credential theft → cloud-account compromise. If ever added, behind a louder opt-in than the others and/or hard-tenant isolation.
-
Request / grant split (separation of duties). The pipeline author requests a grant on a step (carries no authority). The Environment admin grants it via a whitelist entry in the Environment’s
ProtectionRules(writable only with the Administer capability). Fail-closed: requested-but-not-granted ⇒ the step is rejected at admission with a diagnostic naming the missing grant (never silently downgraded). A grant is a ceiling, not a default — granted-but- not-requested escalates nothing. -
Self-service vs governed, split by whether the grant escapes the container.
run-as-rootis self-service — author requests it, no admin, no whitelist. Root inside a caps-dropped, unprivileged, seccomp-confined container does not escape the sandbox; it’s the weakest (defense-in-depth) leg of “restricted”. Governing it buys friction, not safety — and a restricted baseline that made every root-needing image an admin ticket would be unusable.add-capabilitiesandprivilegedare governed — Environment whitelist, digest-pinned, Administer-only. These cross the isolation boundary.
The floor keeps every hard boundary (no privileged, caps dropped, no priv-esc, seccomp, no host namespaces); authors may self-opt-out only of the soft non-root leg. Net posture is stricter than GHA/GitLab (which run as root freely) yet usable without tickets.
-
Keying tiered to blast radius.
privileged→ digest (@sha256:…) only, forever. No signature shortcut for the node-escape hammer; the friction (an admin re-approves exact bytes on every image bump) is the separation-of-duties control.run-as-root/add-capabilities→ digest now; a signature/provenance rule allowed later, once 0015 ships.- Until provenance exists → digest-only for all. So this ADR ships with zero dependency on unbuilt provenance machinery. Recommended operational pattern for third-party privileged images: mirror into your own registry and self-sign on ingest.
-
Enforcement. Admission (in the engine — 0037 moved it there) computes the admitted grant-set as a pure function of
{the run's Environment whitelist, the step's request, the fork-lockout flag}. The admitted set flows to the executor, which applies exactly it to the PodSecurityContextand fail-closes — it never applies an escalation admission did not bless.
Consequences
Section titled “Consequences”- Privileged requires an Environment. Grants come only from an Environment whitelist, so a run
with no environment target (ordinary PR CI) cannot obtain
add-capabilities/privileged— they fail closed. Acceptable: the classic “need privileged in CI” case (DinD image build) is already served unprivileged by rootless BuildKit (0018). - Fork-PR lockout extends to grants. As fork PRs get no secrets
(0037), a fork-PR run gets no governed grants — reusing the
existing
locked_outflag. An external PR author can never request privileged. - A restricted baseline will break root-assuming images until they add
run-as-root— a deliberate, self-service, one-line opt-out. Documented, not silent. - Work to build: baseline
SecurityContexton the normal step Pod; the grant vocabulary + whitelist onProtectionRules(pure domain); thesecurity:request field onStepSpec+ validation; the pure admitted-grant-set function; executor application + fail-close; a Postgres migration for the whitelist; fork-lockout suppression.
Alternatives considered
Section titled “Alternatives considered”- A binary
privilegedflag — one switch that grants everything. Rejected: foot-gun, audit-blinding, no least-privilege. - Folding
host-path/host-networkunderprivileged— technically wrong (privilegeddoes not include host namespaces) and would blind the audit. Rejected; they are distinct orthogonal grants, deferred. cluster-accessin v1 — a step talking to Scarab’s own cluster API is a privilege-escalation path to the control plane on a soft-multitenant system. Rejected; use kubeconfig-as-secret.- Signature keying for
privileged— more ergonomic but trusts the signer forever rather than the exact bytes an admin approved, adds I/O to admission, and depends on deferred provenance. Rejected for the node-escape grant; allowed later for the low-risk grants only. - Governing
run-as-root— maximally strict but makes the restricted baseline an admin-ticket generator for benign images. Rejected in favour of self-service, since root cannot escape the hardened sandbox.