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,yoe deploy). These are protected by the apk signing chain — see apk 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, and what limits exist.
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 apk signing pipeline. The project signing key lives at
~/.config/yoe/keys/<project>.rsa. 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. - Apks are signed and verified. Output
.apkfiles are signed with the project key, the public key is published to the repo and embedded in the rootfs, and on-deviceapk add/yoe deployreject unsigned or wrongly-signed packages. See apk Signing. This protects the device → repo channel; it does not protect the host that produces the apks. - 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.
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.