- Zig 100%
- added fix for gnu distros that use musl from false musl label for the entire distro Reviewed-on: #13 |
||
|---|---|---|
| assets | ||
| docs | ||
| examples | ||
| recipes | ||
| src | ||
| .gitignore | ||
| build.zig | ||
| LICENSE | ||
| README.md | ||
A user package repository that can't run your recipe's shell, because there isn't one.
The AUR's malware problem comes from one design decision: a PKGBUILD is a bash
program. Anyone can upload one, and anything bash can do, a PKGBUILD can do:
curl | sh, base64 payloads, Unicode tricks that hide code from reviewers,
silently swapping the source URL. Reviewing every update by eye doesn't scale.
License
What's this is for
Axiom replaces the program with data and puts everything that must still execute into a box:
| Problem in the AUR | What Axiom does |
|---|---|
| Recipes are bash scripts | Recipes are a strict declarative format. Commands are argv arrays passed to execve; there is no shell anywhere in Axiom. Shells, interpreters, and network/privilege tools are banned as build steps. |
| Hidden code (bidi overrides, zero-width chars, homoglyphs, CRLF, escape sequences) | Recipes must be printable ASCII. Every rejected byte is reported with line, column and name, e.g. U+202E RIGHT-TO-LEFT OVERRIDE (bidi, Trojan Source). |
source= can point at anything, branches move |
https:// only, no IPs, no punycode, no credentials. Source is pinned to a full commit hash; an optional tag must still resolve to that commit or the build stops. |
-bin packages download unverified blobs |
Prebuilt binaries are used only with a SHA-256 in the recipe, only if they match your arch + libc, and a mismatch is a hard failure, never a fallback. |
Builds run as you, with your $HOME, SSH agent and network |
Builds run in fresh user/mount/PID/IPC/UTS/network namespaces: no network, no $HOME, host read-only. Only the build dir and the output dir are writable. |
| "Did this binary come from that source?" | Builds are reproducible (fixed paths, SOURCE_DATE_EPOCH, normalized output). axiom verify rebuilds from scratch and compares bit-for-bit. |
Dependencies pile up in /usr |
Dependencies live in Axiom's store, linked via rpath. Build-only deps are deleted after the build; removing a package removes what it pulled in. |
Axiom is one static binary (~5 MB, no libc) written in Zig. It runs on Arch,
Debian/Ubuntu, Alpine, Fedora and anything else with a Linux kernel, git,
and curl or wget. It never touches the host package manager.
Example:
How it functions
MP4 Video Link:
Quick start
zig build --release # Zig 0.17 or 0.16 (ReleaseSafe)
./zig-out/bin/axiom doctor # proves the sandbox is actually isolated on this machine
export AXIOM_RECIPES=$PWD/recipes
axiom install libpng # builds ninja (build dep) + zlib (runtime dep) + libpng
axiom env # prints the PATH line to add to your shell
axiom verify libpng # rebuild from scratch, compare bit-for-bit
axiom remove libpng # zlib goes too; nothing else depended on it
Commands
lint <file.axiom>... check recipes (charset, schema, pins, argv policy)
info <name|file> show a recipe and what Axiom would do on this machine
build <name|file>... build into the store without installing
install <name|file>... build/fetch, then add to your profile (name, repo/name or path)
remove <name>... remove from profile and delete now-unused deps
list [--all] installed packages (--all: every store entry)
verify <name> check store integrity and rebuild to prove reproducibility
sync [--dry-run] make the system match packages.axiom (declarative mode)
update pull recipe repos (fast-forward only, optional signature check)
gc [--dry-run] delete store entries unreachable from installed packages
hash <file|dir> sha256 of a file, or Axiom tree hash of a directory
new <git-url> [--tag T] write a starter recipe: pins the commit, detects build system and license
bump <name|file> [--tag T] move a recipe to a new upstream tag (lists tags without --tag)
env print paths and the PATH line for your shell
doctor check tools and prove the build sandbox is isolated
Global options: --binary, --root <dir>, --sandbox auto|native|bwrap,
--unsafe-no-sandbox, --keep-work, --rebuild, -j <n>.
A recipe
name = "libpng"
version = "1.6.44"
summary = "Official PNG reference library (installs pngfix)"
license = "libpng-2.0"
homepage = "https://github.com/pnggroup/libpng"
[source]
git = "https://github.com/pnggroup/libpng.git"
commit = "f5e92d76973a7a53f517579bc95d61483bf108c0"
tag = "v1.6.44"
[build]
system = "cmake"
deps = ["ninja"] # build-only: collected after the build
args = ["-G", "Ninja", "-DPNG_TESTS=OFF", "-DPNG_STATIC=OFF"]
[runtime]
deps = ["zlib"] # linked via rpath into the store
Build systems: cargo meson cmake autotools make go zig none, or explicit
[[step]] argv lists. The full format, every rule and the reasoning behind
them are in docs/RECIPE.md.
Packaging Zig programs
axiom new https://codeberg.org/someone/tool.git --tag v1.2.0 # writes tool/tool.axiom
$EDITOR tool/tool.axiom # fill in summary, read it
axiom verify ./tool/tool.axiom # build + rebuild, compare
axiom new resolves the tag to its commit (annotated tags are peeled), checks
the source out once to detect the build system and license, and writes a
recipe with deps = ["zig"]. The zig recipe is the official toolchain
tarball, pinned by sha256; it's a build-only dependency, so it's removed again
after the build. The zig template runs:
zig build --fetch=all # network, lockfile phase only
zig build install --release=safe -Dcpu=baseline -j{jobs} --prefix {out} # offline
--release=safe works whether or not build.zig sets a preferred optimize
mode. -Dcpu=baseline keeps the binary portable across CPUs and reproducible
across build machines. If build.zig doesn't call standardTargetOptions,
-Dcpu would be rejected, so axiom new writes an equivalent [[step]]
without it. For a new upstream release, run axiom bump tool --tag v1.3.0.
Source vs. binary
- If a recipe has a git source, Axiom always builds from source at the pinned commit.
- A
[binary.<arch>-linux-<gnu|musl|static>]entry is used only when you ask (--binary, orprefer_binary = true) or there is no source, and the triple fits this machine (Axiom detects glibc vs. musl), and the SHA-256 matches. - Untrusted archives are unpacked inside the sandbox.
Declarative mode
If packages.axiom exists in the config dir (~/.config/axiom/ per user,
/etc/axiom/ as root), it becomes the single source of truth:
packages = [
"lua",
"libpng",
]
prefer_binary = false
axiom sync builds everything first and only then commits the new set, so a
failed build leaves your system exactly as it was. Anything not listed is
removed along with its dependencies. While the file exists, axiom install /
remove refuse and tell you to edit the file instead. axiom sync --dry-run
shows the plan.
Where things live
~/.local/share/axiom/ (root: /var/lib/axiom, or $AXIOM_ROOT)
store/<hash>-<name>-<ver>/ immutable package trees (0444/0555, fixed mtimes)
db/pkgs/<store-name> output hash, commit, deps, build deps
db/roots what you explicitly installed
profile -> profiles/<n>/ bin/ and share/ symlinks; this goes on $PATH
~/.config/axiom/ (root: /etc/axiom)
axiom.conf options and recipe repositories
packages.axiom optional: switches on declarative mode
Store paths are input-addressed: the hash covers the exact recipe bytes, patches, build mode and the store paths of every dependency. Change anything and you get a new path. Nothing is ever modified in place.
axiom.conf:
prefer_binary = false
jobs = 0 # 0 = all CPUs
sandbox = "auto" # auto | native | bwrap (config can't disable it)
recipes = ["/srv/my-recipes"]
[repo.main]
git = "https://example.org/axiom-recipes.git"
branch = "main"
allowed_signers = "main.allowed_signers" # SSH keys trusted to sign the repo, and/or
gpg_keys = "main.asc" # GPG public keys trusted to sign it
Package names and repositories
A package name means "the recipe with that name", so with several sources it matters which one you get:
axiom install matrix/gopherbooktakes it from thematrixrepository only.axiom install gopherbookworks when exactly one source has it. If several do, Axiom never guesses:- if the package is already installed from one of them, it keeps using that one;
- otherwise, at a terminal, it lists each source with its version and summary and asks which one you want (Enter cancels);
- with no terminal to ask (scripts, CI, pipes), it stops and tells you to
name one, e.g.
matrix/gopherbook.
- An installed package remembers where it came from (
axiom listshows it). If a plain name later resolves somewhere else, for example because the original repository dropped it and another one has a recipe with the same name,installandsyncrefuse before fetching anything. Naming the new source (other/gopherbook) is how you opt in. - A recipe's dependencies come from its own repository first;
depsmay also name one exactly, asrepo/name. - Only repositories listed in
axiom.confcount. Removing one from the config stops it supplying recipes, even though its checkout stays on disk.
axiom update only fast-forwards: a recipe repo whose history was rewritten
is treated as compromised and refused.
Setting allowed_signers (a standard git allowed-signers file:
email ssh-ed25519 AAAA...) or gpg_keys (an exported OpenPGP public key
file) makes the newest commit's signature mandatory, and only those keys
count. Key files are resolved relative to the config dir. GPG keys are checked
in a private keyring Axiom creates for each update, never yours, so keys you
happen to have imported (or a keyserver auto-fetch) can't vouch for a repo.
require_signed = true on its own still works but trusts every key in your
GnuPG keyring, and Axiom warns about it. Unknown or mistyped keys in a
[repo.*] table are errors, so a typo can't silently switch checking off.
The sandbox
/usr /etc /bin /lib* |
host toolchain, read-only |
/dev |
null zero full random urandom tty only |
/proc |
private PID namespace |
/build |
the work dir (read-write) |
| store | read-only, except this package's output dir |
$HOME, /var, /run, /root |
not present |
| network | none (except the lockfile fetch phase; see below) |
Backends: native namespaces (no setuid helper), falling back to bubblewrap
where unprivileged user namespaces are restricted (e.g. Ubuntu's AppArmor
policy). If neither works, Axiom refuses to build unless you pass
--unsafe-no-sandbox.
Cargo, Go and Zig fetch dependencies from their lockfiles in a separate phase
that has network but cannot see the output directory; the build itself then
runs offline (--offline, GOPROXY=off).
What Axiom does not solve
Being honest about the boundary matters more than marketing:
- A malicious upstream project is still malicious. Axiom guarantees you build exactly the commit the recipe names, that the recipe can't add code of its own, and that the build can't phone home or read your files. It can't make the program you then run trustworthy. Pinning means a hijacked upstream can't silently change what you get, and reviewers have a fixed commit to look at.
- Upstream build systems still run code (
configurescripts,build.rs). That code is sandboxed, not trusted. - The host toolchain is visible (read-only
/usr). Reproducibility across machines therefore depends on matching compiler versions; toolchains can be packaged as Axiom recipes to pin them too. Seerecipes/go, a build-only Go toolchain from the official release + SHA-256 that is deleted after use.
Tested
On Ubuntu 24.04 x86_64, Zig 0.16.0 and 0.17.0: zlib, ninja, libpng and lua
built from pinned commits as root and as an unprivileged user; zlib, lua and
libpng verified bit-for-bit reproducible. Lua produced the identical output
hash for two different users with different store paths. The go build
system was tested on goawk (also bit-for-bit reproducible), and binary
installs plus [install] links on a SHA-256-pinned release archive. Wrong
hashes, moved tags, tampered store files and the four hostile recipes in
examples/malicious/ are all rejected.
Not yet exercised: the bubblewrap fallback (bwrap wasn't installable in the
test environment); the go and gopherbook recipes themselves (go.dev and
git.jester-designs.com weren't reachable from the test environment); and
non-x86_64 hosts (the code handles aarch64/riscv64
syscall differences, but hasn't run there).
Layout
src/sys.zig raw Linux syscalls (version-independent; no std.fs / std.Io)
src/proc.zig fork/execve, never a shell
src/sandbox.zig namespaces, pivot_root, read-only binds; bwrap fallback
src/recipe.zig byte lint, strict TOML-subset parser, schema + policy
src/builder.zig planning, git pinning, downloads, build phases, verify
src/store.zig store, records, GC, profile generations, tree hashing
src/config.zig axiom.conf, packages.axiom, recipe lookup
src/main.zig CLI
recipes/ gopherbook, go (toolchain), zlib, ninja, libpng, lua
examples/malicious/ recipes Axiom must reject
docs/RECIPE.md format specification