Security and Threat Model
[yoe] is a build system. It runs arbitrary code that you and the modules you
import write — Starlark logic plus shell scripts inside a build container. This
page describes what that container actually protects you against, what it does
not, and how to think about trusting the code yoe will execute on your behalf.
The short version: treat every unit and every module the same way you treat a
curl | sh URL. If you import it, you are running it. The build container is
a convenience for hermetic toolchains, not a security boundary.
Threat model
[yoe] is designed for a developer or build operator running it on a machine
they control, against source code and modules they have chosen to import. It is
not designed to safely execute untrusted units.
In particular:
- Trusted. You. Your project’s
PROJECT.star, your project’s unit.starfiles, every module you list inmodules = [...], every upstream source URL and git remote those units pull from. - Untrusted. The booted target device, network traffic to/from on-device
package installs (
apk add/apt install,yoe deploy). These are protected by the package signing chain — see Package Signing. - Out of scope. Running other people’s
PROJECT.starfiles, hostingyoebuilds on a multi-tenant machine, sandboxing one project from another on the same host.yoedoes not attempt any of this today.
If the question is “can a rogue unit damage the host?”, the honest answer is yes, easily. The rest of this page explains why, what limits exist today, how this will be improved in the future.
Architecture: Starlark for declaration, Go for execution (planned)
Status: Planned. Today,
run(..., host=True)andrun(..., privileged=True)are accessible from Starlark and are used extensively by the in-tree classes (container.starshellsdocker buildon the host;image.starrunsdpkg --configure -awithprivileged=Trueto chown the rootfs). A rogue unit can therefore reach the host shell and root-in-container directly from a class body. The planned cutover restricts every host call and every privileged-container call to Go code paths, removing those kwargs from the Starlark surface. See docs/specs/2026-05-20-starlark-unprivileged-only.md for the spec and current implementation status.
The architectural rule yoe is converging on:
All host-shell execution and all privileged-container execution happens in Go code. Starlark declares what work to do; Go decides whether to run it, in which sandbox, and with what privileges.
Concretely:
- Starlark units declare data — name, version, source URL, runtime deps,
install task steps — and assemble that data into
*Unitstructs through builtins (unit(),image(),container(), …). - Go code consumes those structs and decides everything that actually
touches the host or runs privileged: which container to spawn, which sandbox
profile to apply, which paths to mount, which capabilities to drop, whether to
shell out to
docker buildxordpkg --configure -a. - The
run(...)Starlark builtin survives for unprivileged in-container shell steps — the install task body of a feed unit extracts a tarball, the build task of an autotools unit runs./configure && make. These run in the per-unit bwrap sandbox where the worst a rogue command can do is corrupt its own destdir.
Why this matters: a small Go audit surface for arbitrary Starlark
Starlark has no I/O primitives of its own. It can’t open files, can’t spawn
processes, can’t make network calls. Every operation that escapes the Starlark
interpreter does so through a Go-side builtin the embedding program registered.
yoe’s embedding registers a small, enumerable set: unit, image, container,
machine, task, run, alpine_feed, apt_feed, resolve_closure, and a
handful of supporting builders. The complete list lives in
internal/starlark/builtins.go.
Once every Go-side builtin is audited — does it sandbox the work it spawns? does it refuse to escape the build directory? does it drop privileges before running shell? — the answer for every possible Starlark unit is the same answer. A new module a user imports next year cannot expand the attack surface beyond what the existing Go builtins permit. The Starlark code is data; the policy lives in Go.
Contrast with Python or JavaScript embeddings, where the unit author can call
subprocess.run, os.system, fetch, eval, import os — the language’s
standard library is the attack surface, and there is no enumerable list of
escape hatches an auditor can walk to be sure they’ve seen everything. A
pure-data DSL like Starlark inverts this: a security review enumerates the
embedded builtins, audits each one in Go, and is done. Adding a new builtin is a
deliberate choice the maintainer makes; adding a new Starlark unit cannot expand
what the system is capable of.
This is the single biggest leverage point in yoe’s security model. The build
container, signing chain, repo trust, and on-target apk verification all sit on
top of “Starlark is data, Go is policy.” Once the transition is complete, the
threat model changes from “every imported module is curl | sh” to “every
imported module is data fed to an audited Go program” — still not zero-trust,
but the audit surface stops growing with the number of imports.
The remainder of this page describes the current state (where some of this is already true and some isn’t) and what the build container actually protects you against today.
How a build actually runs
Every yoe build step that needs a toolchain runs in a Docker (or Podman)
container launched by internal/container.go. The relevant flags are:
docker run --rm --privileged \
--user <host-uid>:<host-gid> \
-v <projectDir>:/project \
-v <srcDir>:/build/src \
-v <destDir>:/build/destdir \
-v <sysroot>:/build/sysroot:ro \
-v <cacheDir>:<containerPath> ... # per-unit cache_dirs
-w /project \
<image> sh -c '<build command>'
Two things in that command line dominate the security picture:
--privilegedis unconditional. Every container yoe launches is privileged. That means all Linux capabilities are granted, the host’s/devis exposed (or near-equivalent on Docker), AppArmor/SELinux profiles are not enforced, seccomp is off, and/sysis read-write. The container is not a sandbox in any meaningful sense — it is a chroot with a toolchain.--user uid:gidruns as you, except when it doesn’t. Most steps drop to the host user, so files written to mounted paths are owned by you and the container cannot directly write host devices that require root. But several paths run as root in the privileged container (see below), and at that point a hostile build step can write/dev/sda, load kernel modules, or pivot the mount namespace and escape.
Code paths that run as root in the privileged container
| Path | Why |
|---|---|
image-class units | apk extraction (preserves per-file uid/gid from package tar metadata), mkfs.ext4 -d, losetup, mount, extlinux |
Any run(..., privileged = True) in a unit | Same, exposed as a Starlark builtin |
QEMU device runner (internal/device/qemu.go) | Needs /dev/kvm |
Bootstrap stage 1 (createBuildRoot) | apk add --root builds the build root |
yoe cache clean and yoe build --clean | Removes the resulting root-owned files from build/ since the host user can’t rm them |
For these paths, container UID is root, all caps are present, and the host’s
/dev is reachable. There is no defense-in-depth layer beneath that.
The apk-extraction step on the image-class row deserves a short note: it runs
as root in the container so that chown(path, hdr.uid, hdr.gid) calls during
tar extraction actually succeed, which is what makes the assembled rootfs
contain (and the resulting ext4 image preserve) per-file ownership like
navidrome:navidrome for /var/lib/navidrome or postgres:postgres for
/var/lib/postgresql. The earlier workaround — chown -R 0:0 on the rootfs
followed by a chown-back-to-host at end-of-build — collapsed all ownership to
root and obscured what the booted system would see; the current path preserves
real ownership and accepts the cost that build/<image>.<arch>/destdir/rootfs/
is owned by root after a build. yoe cache clean and yoe build --clean route
cleanup through the same container so the host user doesn’t need sudo for
routine work. See
Comparisons § Rootfs Ownership
for why this is preferred over LD_PRELOAD (fakeroot/pseudo) or user-namespace
(bwrap) alternatives.
run(host = True) — there is no container at all
Units can ask Starlark to execute commands directly on the host:
run("docker build -t %s -f %s/%s %s" % (tag, name, dockerfile, name), host = True)
This is how modules/module-core/classes/container.star builds container images
on the host’s Docker daemon. The command runs through bash -c as your host
user, in cfg.HostDir (usually the unit’s .star directory). There is no
namespace, no mount restriction, no /project-only view. A unit that uses
host = True has a shell as you. It can read ~/.ssh, write
~/.config/yoe/keys/, or rm -rf ~.
The bwrap layer
Build steps that opt into sandbox = True are wrapped with bwrap inside the
container (internal/build/sandbox.go). The bwrap call binds / to /,
read-only-binds /proc and the sysroot, and tmpfs’s /tmp. This is sysroot
hygiene, not isolation — it prevents a unit from accidentally linking against
host libraries during a hermetic build. The whole bwrap invocation is inside the
same privileged container, so a unit that wants to escape can simply call exit
from bwrap and run anything it likes outside it, or call
run(privileged = True) to skip bwrap altogether.
What a rogue unit can do
Given the above, a unit author can — without warning, prompts, or visible side
effects in yoe build output:
- Read and modify the entire project tree.
/projectis mounted read-write. That includesPROJECT.star, every other unit, the build cache, the apk repo, signing public keys, build logs. - Read every source the build has pulled.
cache/sources/andcache/modules/typically map under the project, but a unit’scache_dirscan bind any directory the user has access to. - Read environment variables passed to the build. The Go process exports
them into the container via
-eflags. - Execute arbitrary host commands as you via
run(host = True). This bypasses the container entirely. There is no allowlist, no path restriction, no “are you sure?” prompt. The unit author has the full power of your shell. - Run as root in a privileged container via
run(privileged = True)or by declaringunit_class = "image". From there:- Overwrite
/dev/sda,/dev/nvme0n1, USB sticks, any block device the kernel exposes. - Load kernel modules into the host kernel (
insmod,modprobe). - Modify host firewall rules (
iptables,nft) — the privileged container shares the network namespace by default, so changes are host-wide. - Read
/proc/<host-pid>for every process on the host. - Mount any host filesystem and exfiltrate or modify it.
- Trigger any of the well-known privileged-container escapes
(
/sys/kernel/uevent_helper,core_pattern, cgrouprelease_agent, etc.) to spawn a process on the host as root.
- Overwrite
- Tamper with the package signing pipeline. The project’s apk signing key
lives under
~/.config/yoe/keys/(the RSA key at<project>.rsa); the apt repository served to Debian/Ubuntu targets is likewise signed by a per-project key. Arun(host = True)step trivially reads it. A privileged in-container step can read it if it lives under/projector any mounted cache dir; the default location is in your home, which the container does not see — butrun(host = True)does. - Poison the cache. A unit can plant files in
cache/sources/,cache/modules/, or per-unitcache_dirsmounts so the next build of another unit picks up tampered content.
What yoe does limit
The container does provide some friction. It is worth being precise about what:
- Unit builds that don’t go privileged see only
/projectand their mounts. A run-of-the-millmake && make installstep running as your host UID cannot reach$HOME, system files outside/project, or the apk private key in~/.config/yoe/keys/. It can still corrupt anything inside/projectand the configured cache dirs. - Most builds run as your host UID, not root. Even with
--privileged, a non-root container process cannot write block devices owned byroot:diskor callmount(2)directly. A unit has to deliberately escalate viaprivileged = True,unit_class = "image", orhost = Trueto escape this. - Packages are signed and verified. Output packages (
.apkon Alpine,.debon Debian/Ubuntu) are signed with the project key, the public key is published to the repo and embedded in the rootfs, and on-device installs (apk add/apt install,yoe deploy) reject unsigned or wrongly-signed packages. See Package Signing. This protects the device → repo channel; it does not protect the host that produces the packages. - Source archives can declare integrity hashes. Units that set
sha256 = "…"orapk_checksum = "…"get post-download verification ininternal/source/fetch.go. Units that omit both run whatever the upstream returned.
Trust model for code yoe executes
yoe does not validate the units it loads. The trust chain for a build is:
PROJECT.star— you wrote it (or you imported it from a project you trust). It declares modules withmodule(url = ..., ref = ...).- Modules are fetched with
git clone --depth 1 --branch <ref>intocache/modules/<name>/(internal/module/fetch.go).refis a branch or tag name — not a commit hash. A module upstream that retags a release ships you the new content on the nextyoe module sync. The cache also trusts whatever bytes are already on disk; once cloned, integrity isn’t re-checked. - Unit sources come from the URL in each unit’s
source = ...field, fetched via HTTPS, HTTP, or git. Integrity is verified if and only if the unit declaressha256orapk_checksum. Git sources rely on the tag pointing at the right commit at clone time. - The build container image is built from a Dockerfile in
module-core,module-alpine, or another module — i.e., from the same supply chain as the units. It is not a vendor-supplied vetted image.
There is no signature on modules, no commit-hash pinning, and no notion of “yoe-approved upstreams.” If you import a module, you are running it.
Practical guidance
- Only run
yoeon projects you control or that came from sources you trust. Read the modules list. Audit unit.starfiles the same way you’d audit a shell script. Theaudit-unitskill (docs/ai-skills.md) is a useful first pass. - Don’t run
yoe buildon a shared or production machine. A build step withhost = Trueorprivileged = Trueis one careless module pin away fromrm -rf ~or worse. - Don’t put secrets in the project tree.
/projectis fully readable and writable by every build step. Keep API keys, deployment credentials, and signing material outside the project directory, where the container’s default mount cannot see them. (host = Truecan still see them — see above.) - Pin modules to release tags you’ve reviewed, and re-review on upgrade. Until commit-hash pinning lands, the tag name is the trust anchor, and tags are mutable.
- Be careful with
yoe module dev. Putting a module into dev mode means yoe uses your local checkout. If you also have an unrelated branch checked out there, those unit definitions are what the build will run. - Declare
sha256orapk_checksumon every source archive you can. Even for sources you trust, an integrity check catches MITM, mirror compromise, and accidental retags. - Keep
~/.config/yoe/keys/permissions tight (mode 0600 on the private key) and use distinct project names to avoid signing-key reuse across unrelated projects.
Known weaknesses we’d accept patches for
These are explicit gaps, not unintentional bugs. PRs welcome.
- Drop
--privilegedfor the common case. Most build steps (gcc, make, Go, Python wheels) don’t needCAP_SYS_ADMIN. The flag is currently unconditional because image-class units need it; splitting the container invocation into privileged and non-privileged variants — and only escalating for the steps that genuinely need it — would dramatically reduce the host blast radius. - Remove
run(host = True)andrun(privileged = True)from Starlark. The two kwargs that make “rogue unit = host compromise” trivial today. Spec’d in Starlark unprivileged-only: delete both kwargs and move image-class and container-class privileged operations into Go drivers ininternal/. Only two.starfiles in the whole tree use the kwargs today, both yoe-shipped classes, so the migration is bounded. - Pin modules by commit hash, not by ref. A
module(ref = "v1.4.0", commit = "<sha>")form, verified at clone and fetch time, would close the “upstream retagged the release” hole. - Verify cached modules on reuse.
SyncIfNeededtrusts whatever bytes are incache/modules/<name>/. A simple manifest of expected commit hashes per module would detect tampering by other processes on the host. - Replace privileged loop/mount with a Go image assembler. Tracked in
Build Environment §“Reducing Dependence on Docker’s /dev”.
The same change removes
--privilegedfrom the image-assembly path. - Add a
--paranoidmode that refuseshost = Trueandprivileged = True. Useful for CI builds and for projects that want to fail loudly when a module tries to escape the container. - In-tree signing keys + APKINDEX share the same access-control gate.
module-alpine’skeys/directory andfeeds/*/APKINDEXlive in the same git repo under the same maintainer write access. A compromised maintainer account can add a new trusted key + an APKINDEX signed by it in one commit, with no second factor. The maintainer playbook (docs/module-alpine.md, when feeds-as-modules lands) should flag key additions as higher-trust than routine APKINDEX refresh; longer-term mitigations include consuming-project-level key declarations or a signed-off-by CI gate on key-file changes.
Where to look in the source
If you want to verify any of the claims above:
internal/container.go—containerRunArgsbuilds thedocker runline.--privilegedis at line 162.internal/build/sandbox.go— bwrap invocation and what it binds.internal/build/starlark_exec.go— therun()builtin, including thehost=Trueandprivileged=Truebranches.internal/build/executor.go—chownDirToHost, the root-recovery path.internal/image/disk.go— image-class unit’slosetup/mountflow.internal/module/fetch.go— module clone/fetch logic and lack of commit pinning.internal/source/fetch.go— source fetch and the SHA256 / apk_checksum verification.