Refuse a compile with no explicit target; add the musl loader package
P1.3, channel half.
THE EXPLICIT-TARGET GUARD. toolchain/bin/zig was a symlink to the zig binary; it is now a guard that refuses zig cc / c++ / build-* / translate-c with no explicit -target and only then execs the real binary. Zig does not fail when it has nothing to detect -- it guesses, silently. Measured on this toolchain: bare zig cc bakes the interpreter /gnu/store/<hash>-glibc-2.41/lib/ld-linux-x86-64.so.2 into every executable it links, which is not a declared input and does not exist inside the sandbox. The musl wrappers exec the guard too, so it sits on the hot path of every compile rather than in a corner where it could rot unnoticed.
The guard asserts its own instrument before interpreting anything: a missing or dangling zig exits 70 naming a broken toolchain, never 78 blaming the caller and never a silent 127. It is a defect detector on the invocation, not a security boundary -- a build reaching past $PATH for the real binary is not stopped by it, and the structural backstop is the post-link ELF check.
Six-case matrix, both directions, in ~/Ops/workspaces/sigil-packaging-stage1-p13/evidence/zig-guard-matrix.log: bare cc -> 78 with no output file; -target -> 0 with a real ELF; the musl wrapper -> 0 and statically linked; zig version / zig ar pass through; broken toolchain -> 70; bare c++ -> 78.
THE MUSL LOADER PACKAGE. core/musl builds musl 1.2.5 -- the version zig 0.16.0 bundles, read from lib/libc/musl/src/internal/version.h in the pinned toolchain, so the loader and the libc executables link against are the same release rather than merely compatible. Most packages will never need it: x86_64-linux-musl-cc links static by default and GNU hello has no INTERP segment at all. It exists so the DYNAMIC case is expressible instead of blocked, with the loader arriving as a declared input rather than ambient.
It carries one channel patch. musl installs lib/ld-musl-x86_64.so.1 as an ABSOLUTE symlink into its own /sigil/store prefix, which resolves only inside the sandbox -- and the bind is assembled on the host, before the chroot. The patch makes the symlink relative to its own directory so it resolves from both sides. Measured: applies cleanly under the builder's own patch -p1 -N -i, and the built item's loader resolves to a real 728 KB ELF shared object on the host. Without it the builder now refuses at assembly time and names the dangling symlink.
channel.sgl | 2 ++
src/core/packages/libc.sgl | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/patches/musl-relative-ldso-symlink.patch | 24 ++++++++++++++++++++++++
toolchain/setup.sh | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 178 insertions(+), 1 deletion(-)channel.sglmodified
(import (core packages wasm) (core packages demo) (core packages compression) (core packages libc) (core packages terminal) (core packages xml) (core packages databases) (channel-binding name: "core/binaryen" value: binaryen) (channel-binding name: "core/hello" value: hello) (channel-binding name: "core/zlib" value: zlib) (channel-binding name: "core/musl" value: musl) (channel-binding name: "core/ncurses" value: ncurses) (channel-binding name: "core/libxml2" value: libxml2) (channel-binding name: "core/sqlite" value: sqlite)src/core/packages/libc.sgladded
;;; The C runtime, as a package.;;;;;; WHY A LOADER PACKAGE EXISTS AT ALL (P1.3).;;;;;; Inside the build sandbox a dynamic loader is never ambient. It appears at;;; its conventional absolute path (`/lib/ld-musl-x86_64.so.1`) only because;;; some DECLARED dependency ships it in its store item, and nothing else in;;; the namespace can supply one. That is deliberate: a loader resolved from;;; whatever the host happened to have is a machine-specific input that no;;; derivation record mentions, and the spike measured what it costs when the;;; guess lands badly -- a segfaulting `temacs`, with no error anywhere.;;;;;; So a package that produces a DYNAMICALLY linked musl executable must be;;; able to say where its loader comes from. This is that package. Depend on;;; it and the loader is bound; do not, and a dynamic executable fails at exec;;; with ENOENT rather than silently running against something unrecorded.;;;;;; MOST PACKAGES DO NOT NEED IT. `x86_64-linux-musl-cc` links STATIC by;;; default (measured: GNU hello built through it has no INTERP segment at all;;; and runs), so the whole channel builds without a loader today. This;;; package is what makes the dynamic case EXPRESSIBLE rather than blocked.;;;;;; THE VERSION PIN IS MEASURED, NOT CHOSEN. zig 0.16.0's bundled musl is;;; 1.2.5 -- `lib/libc/musl/src/internal/version.h` in the pinned toolchain;;; reads `#define VERSION "1.2.5"`. Pinning the same release keeps the;;; loader and the libc that executables were LINKED against identical rather;;; than merely compatible. If the toolchain is re-pinned, re-read that file;;; and move this with it.(define-library (core packages libc) (import (sigil core) (core build-environments) (sigil env package) (sigil build external)) (export musl) (begin (define musl (package name: "musl" version: "1.2.5" source: (external-source location: "https://musl.libc.org/releases/musl-1.2.5.tar.gz" sha256: "a9a118bbe84d8764da0ea0d28b3ab3fae8477fc7e4085d90102b8596fc7c75e4") buildenv: core-buildenv phases: (list ;; WHY THIS PATCH EXISTS. musl installs ;; `lib/ld-musl-x86_64.so.1` as a symlink to ;; `$(libdir)/libc.so` -- an ABSOLUTE path, which here is ;; `/sigil/store/<item>/lib/libc.so` and exists only inside ;; the sandbox. The sandbox assembly runs on the HOST, ;; before the chroot, so it cannot resolve that symlink and ;; cannot bind the loader. ;; ;; Measured on musl 1.2.5's own Makefile rule at line 213. ;; Without the patch the builder now REFUSES at assembly time ;; and names the dangling symlink; before P1.3 it skipped the ;; bind silently and the failure surfaced as an ENOENT at ;; exec, nowhere near the cause. ;; ;; The patch makes the symlink RELATIVE to its own directory, ;; so it resolves identically from inside and outside. (external-phase kind: 'patch patches: (list (channel-patch path: "patches/musl-relative-ldso-symlink.patch" sha256: "94a0cb858be6713634145d1cf0ddd7ab31f9367e915f3a276b311de9f10d59e9"))) ;; `--syslibdir` is what decides where `ld-musl-x86_64.so.1` ;; lands. Its default is `/lib`, which would be an absolute ;; host path OUTSIDE this package's own prefix -- the install ;; would escape DESTDIR exactly the way ncurses' terminfo ;; database does without its `ticdir` pin. (external-phase kind: 'configure args: (list "--prefix=${out}" "--syslibdir=${out}/lib")) (external-phase kind: 'make) (external-phase kind: 'install))))))src/patches/musl-relative-ldso-symlink.patchadded
Make the dynamic-loader symlink RELATIVE to its own directory.musl installs lib/ld-musl-x86_64.so.1 as a symlink to $(libdir)/libc.so, anABSOLUTE path. In this channel $(libdir) is the package's own store prefix,/sigil/store/<item>/lib, which exists only inside the build sandbox. Thesandbox assembly runs on the HOST, before the chroot, so it must be able toresolve the loader from outside: an absolute self-referential symlink isdangling there and cannot be bind-mounted.A relative symlink names the sibling file and therefore resolves identicallyfrom both sides. Nothing else changes; the installed loader still points atthe same libc.so.--- a/Makefile+++ b/Makefile@@ -210,7 +210,7 @@ $(INSTALL) -D -m 644 $< $@ $(DESTDIR)$(LDSO_PATHNAME): $(DESTDIR)$(libdir)/libc.so- $(INSTALL) -D -l $(libdir)/libc.so $@ || true+ $(INSTALL) -D -l libc.so $@ || true install-libs: $(ALL_LIBS:lib/%=$(DESTDIR)$(libdir)/%) $(if $(SHARED_LIBS),$(DESTDIR)$(LDSO_PATHNAME),) toolchain/setup.shmodified
rm -rf "$root/zig-x86_64-linux-0.16.0" "$root/bin"tar -C "$root" -xf "$archive"mkdir -p "$root/bin"ln -s ../zig-x86_64-linux-0.16.0/zig "$root/bin/zig"# ---------------------------------------------------------------------------# THE EXPLICIT-TARGET GUARD (P1.3)## `bin/zig` used to be a symlink to the zig binary. It is now a guard that# REFUSES a compile or a link with no explicit `-target`, and only then execs# the real binary. The rule it enforces is normative and thrice-measured:# targets are always explicit inside the build sandbox; loaders are inputs, not# ambient. Any "native" invocation inside the sandbox is a bug BY DEFINITION.## WHY A REFUSAL AND NOT A DEFAULT. Zig does not fail when it has nothing to# detect -- it GUESSES, and the guess is silent. Measured 2026-07-31 on this# toolchain: bare `zig cc` bakes the interpreter# `/gnu/store/<hash>-glibc-2.41/lib/ld-linux-x86-64.so.2` into every executable# it links. That is a host-specific, guix-generation-specific absolute path# which is not a declared input, so inside the namespace it does not exist and# GNU hello dies at AC_PROG_CC. The spike measured the same guess landing# somewhere far worse: a SEGFAULTING `temacs`, with no error at all.## WHAT THIS GUARD IS AND IS NOT. It is a defect detector on the invocation, so# the diagnostic names the cause instead of surfacing as an ENOENT or a# segfault hours later. It is NOT a security boundary: a build that reaches# past $PATH for `../zig-x86_64-linux-0.16.0/zig` is not stopped by it. The# structural backstop for "however the condition arrives" is the post-link# artifact check (test/integration/elf-dynsym-collapse-gate.sh in the# monorepo), which reads the produced ELF rather than the command line.## The guard sits on the HOT path: the musl wrappers below exec it too, so every# compile in every build exercises it. A guard nothing routes through is the# vacuous-gate family one step earlier.cat > "$root/bin/zig" <<'EOF'#!/bin/shset -euhere=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)real="$here/../zig-x86_64-linux-0.16.0/zig"# ASSERT THE INSTRUMENT BEFORE INTERPRETING ANYTHING. `-x` resolves symlinks,# so it is false for a dangling one. Without this check a missing or dangling# zig would exec-fail with 127, which reads like a compiler error rather than# like a broken toolchain -- the exact conflation ("the world cannot do this"# vs "I could not ask") that silently degraded every build once already.[ -x "$real" ] || { echo "zig-guard: the zig binary is missing or unusable at $real." >&2 echo "zig-guard: this is a BROKEN TOOLCHAIN, not a compile failure." >&2 echo "zig-guard: re-run toolchain/setup.sh." >&2 exit 70}# `case` rather than `[ ... ] && exec`: under `set -e` a failing `[` is the# status of the whole AND-OR list, so the guard would exit 1 on the FIRST# non-matching argument and never reach the refusal. Removing the construct# beats escaping it correctly.case "${1:-}" in cc|c++|build-exe|build-lib|build-obj|translate-c) for arg in "$@"; do case "$arg" in -target|--target=*) exec "$real" "$@" ;; esac done echo "zig-guard: REFUSING 'zig $1' with no explicit -target." >&2 echo "zig-guard:" >&2 echo "zig-guard: Inside the build sandbox there is nothing for zig's native" >&2 echo "zig-guard: detection to probe, so it does not fail -- it guesses, and" >&2 echo "zig-guard: bakes a host /gnu/store glibc loader into the output. That" >&2 echo "zig-guard: loader is not a declared input and does not exist in the" >&2 echo "zig-guard: namespace, so the result fails at exec or segfaults." >&2 echo "zig-guard:" >&2 echo "zig-guard: Use the wrappers -- x86_64-linux-musl-cc / -ar / -ranlib --" >&2 echo "zig-guard: or pass -target explicitly." >&2 exit 78 ;;esacexec "$real" "$@"EOFchmod +x "$root/bin/zig"cat > "$root/bin/x86_64-linux-musl-cc" <<'EOF'#!/bin/shset -eu