Mechanics superseded 2026-10-06 by one-table-two-instances. The decision stands — a deny under
Identity::Collision, an allow underIdentity::Stored, the rule’sPolaritychoosing — and the measurements below stand as written. The prefix sets that carried it are gone: fs is aRegion, a table whose rules speak by polarity throughFrozenPath::contains::<P>;outsideisTable::live, and the stored-holding test behind the respelled refusal isTable::respelled. The corner cases are gathered in authority-tables.
Denies hold under every spelling
A deny is judged under the coarsest identity any filesystem gives a name;
an allow under the name as stored. One function, lex::collision_key,
computes the deny’s key; the type of a prefix set says which relation it
uses, so a deny can never be asked the exact question.
Context
The guard compared names byte for byte off Windows and by ASCII case on it,
for allows and denies alike. That was right only where every name has one
spelling. On case-insensitive APFS, deny: [cwd:/.env] with .env absent
lost to echo key > .ENV, which left key in the object .env names: the
leaf was absent, so it kept its spelling, and the bytes differed. The deny
re-resolves on every check, so once the object existed every later access
was refused; the hole was exactly the access that creates it, which is the
write a deny is there to stop. NFC against NFD was the same hole, and
non-ASCII case on NTFS too.
Case-insensitivity belongs to the volume, not the OS. synod’s Linux guest
reads APFS and NTFS shares, ext4 and f2fs fold per directory, and APFS has a
case-sensitive variant. So the fold can be neither a cfg nor a question put
to the filesystem (Rejected, below).
What was measured
macOS 26.5: default (case-insensitive) APFS and a case-sensitive APFS image.
Every Seatbelt row ran under (allow default) plus one rule, beside a
non-matching control that was admitted.
- An existing name is already spelled as stored.
realpath(3)andF_GETPATHreturn the directory’s spelling, for case and for normalisation, on both volume kinds. A deny frozen over an existing path carries it; only an absent name keeps the spelling it was written in. - The volume answers per path, and only for what exists.
pathconf(_PC_CASE_SENSITIVE)is0on the Data volume and1on the image, andENOENTfor an absent path. - Default APFS takes two names for one exactly when they agree under
canonical caseless matching (Unicode D145,
NFD(casefold(NFD(s)))), on all 18 probe pairs: ß≡ss, ß≡ẞ, ſ≡s, K (Kelvin)≡k, fi≡fi, σ≡ς, µ≡μ, Å≡Å (angstrom) and NFC≡NFD merge; ı/i, ı/I, İ/i and fullwidth A/a stay distinct. Case-sensitive APFS merges canonical equivalents only. - Seatbelt matches
literal,subpathandregexunder that same equivalence, on every volume, for absent and existing names alike. A deny onSecretsrefusesmkdir secrets, a write undersecrets/andcat SECRETS/t; a deny spelledsecretsrefuses the storedSecrets; an upper-cased ancestor in the rule still matches; an NFC rule refuses an NFD create and the reverse. Its fold is canonical caseless matching, nothing more: ı/i and A/a are admitted. - Seatbelt’s allows fold too. On the case-sensitive volume, where
Workandworkare distinct objects, a deny onWorkrefuseswork, and an allow over-grants:subpath Work,subpath WORK(a name nothing stores) andliteral …/Work/teach admit readingwork/t. - NTFS, on the Windows CI runner, upcases each UTF-16 unit through the
volume’s
$UpCasetable and does not normalise. The table keeps ı apart from I, though Unicode’s simple uppercase joins them:fıleandFILEare two names. - ZFS, from its source (OpenZFS
zfs_vfsops.c,u8_textprep.c, its tables decoded over every code point).casesensitivity=insensitiveormixedupcases each character by Unicode 5.0’s simple uppercase, which sends ı to I, then applies the dataset’snormalization: none,formC,formD,formKCorformKD, both fixed at creation. UnderformKCandformKDit merges compatibility equivalents: fullwidth A≡A, 𝐚≡a, no-break space ≡ space. - Linux, from documentation. Casefold (ext4, f2fs: NFD plus casefold) is canonical caseless matching by construction.
Decision
- The key.
collision_key(name)isNFKD(casefold(NFKD(casefold(NFD(upper(NFKD(name))))))): compatibility caseless matching (Unicode D147) of the uppercase image of the compatibility decomposition, through ICU4X (icu_normalizer,icu_casemap, one Unicode version for every step). ASCII is its lowercase; a name that is not UTF-8 is its own key. Measured over every code point, the shipped key splits no class that APFS, Seatbelt or ZFS under any setting merges; NTFS, Linux casefold and a case-sensitive volume are finer, which is fail-closed. NFKD comes first:𝚤decomposes to ı, which ZFS upcases, yet has no uppercase of its own. A key is a function, so it holds the join of the classes:𝚤meets I, though no one volume merges them. - Two relations, one kernel.
lex::path_withintakes anIdentity:Stored(bytes; ASCII case on Windows) orCollision(collision_keyper component, so no fold crosses a component boundary). - The set’s type picks.
PrefixSet<Allow>meets and judgesStored;PrefixSet<Deny>joins and judgesCollision;outsidedrops an allow by the deny’s relation.fs_verdictis unchanged: the type does the work. Nothing outsidecore/src/path/chooses an identity. - Exec reads names the same way. An exec rule holds a name as its
polarity reads it: an allow dir or file as stored, a deny dir or file under
Collision(RealPath::within::<P>, oneholdsincapability::execchoosingPfrom the rule). A veto’s key iscommand_name_key, the host’s name key undercollision_key. Renaming defeats a veto anyway, which would argue for leaving it stored; butdeny: [bash]is one grant key compiling to a folding exact deny and a veto, and Seatbelt’s veto regex folds, so a stored key would refuse a macOS child/x/BASHthat the guard admits. A folded match ranks exactly as a stored match of the same key, so exec’s precedence is unchanged (exec-rules). - Allows meet, denies join. The exec meet keeps a key met to Deny only
where a layer wrote that deny (
met): a key one layer never mentioned is a default, and written down as a deny it would hold every other spelling of that name.evicts, which widening (--extend-base) sweeps with to drop an allow dir a deny dir clashes with, asks containment underCollisionboth ways: mutual containment is one rank, where the guard’s tie already denies, so the sweep agrees with the verdict. - The denial says why. When a deny holds the access and none holds it
as stored (and, for exec, no veto names it), fs and exec refuse in one
sentence, at the guard and at head admission alike:
fs write denied by grant: /w/.ENV is the denied /w/.env under another spelling (case or Unicode form); a deny holds under every spelling. - Windows 8.3 aliases are named, not folded. A walk that meets a
~opens the leaf, file or directory, with attribute access alone and asks the kernel its long name; a name it cannot learn refuses the access. A short spelling is an alias, not a fold, so no key could close it: the fix names the object. A short spelling can only make an allow miss, a refusal, but it makes a deny miss, an admission. With a deny onC:\w\secretfile.txt, a leaf left spelledSECRET~1.TXTis compared with the long name by the in-process guard, which misses and admits the read; the kernel’s ACE, being on the object, holds for a child.
Why this key
- At least as coarse as every identity in play. A deny that under-merges
lets a spelling through, so the key takes case-insensitive APFS and
Seatbelt, NTFS’s
$UpCase, Linux casefold and ZFS under every setting together. Where a volume is finer, down to byte-exact ext4 and case-sensitive APFS, it over-merges: fail-closed. - Exact on ASCII. The key of an ASCII name is its lowercase, and a non-ASCII character whose key is ASCII (K, ſ, fi, A) lands on that same lowercase, so the ASCII fast path is no approximation.
- Stable. Unicode’s case-folding and normalisation stability policies mean a newer table never splits a class an older one merged.
- One Unicode version.
icu_normalizerandicu_casemapare one ICU4X release, so normalisation and case folding cannot disagree on a table.
Why a deny folds and an allow does not
A fold merges names, and merging a deny’s names withdraws authority while merging an allow’s grants it. Each polarity takes the identity whose error is a refusal.
- A deny that under-merges is the hole: the create of the name slips through. One that over-merges refuses a distinct sibling nobody meant, on a case-sensitive volume.
- An allow that folded would over-grant on every case-sensitive volume: a
grant on
Workwould admitwork. Held as stored, it errs only towards refusing. - Across layers the split carries on: denies join and allows meet by stored name, so two layers that spell one directory differently grant it to neither.
Rejected
- Fold only on macOS and Windows (
cfg). Case-insensitivity belongs to the volume, not the OS, and acfgwould give one grant three meanings. - Ask the filesystem (
pathconfon the nearest existing ancestor,FileCaseSensitiveInformationon Windows).- It makes
covers,outsideandcoveringdepend on the disk at check time; the prefix algebra is a pure function of two policies. - An absent tail inherits nothing knowable: the flag is per directory on Windows and ext4, and is set on an empty directory, so a same-uid writer can create the tail with the other flag after the query.
- Linux has no query for virtiofs or 9p.
- All it buys back is the over-deny of distinct siblings on a case-sensitive volume, which Seatbelt refuses anyway.
- It makes
- Fold allows too. The over-grant above; the Windows allow side’s ASCII fold is a residual, not a precedent.
- Store the key in
real. It is rendered into bwrap destinations, Seatbelt rules and the Windows SID hash, and on a case-sensitive volume a folded spelling names a different object. A comparison key belongs in the comparison, not in the name. - Refuse a deny on an absent path. It breaks the documented
cwd:/.envgrant. - Mask every existing colliding sibling on Linux and Windows, so that the kernel matches the guard’s over-deny. It enumerates directories, races, and adds machinery to enforce an over-approximation.
- NFKC_Casefold. It misses ı≡I, which ZFS merges, and deletes default-ignorable code points (ZWJ, soft hyphen), which no filesystem in play ignores.
- std
to_lowercase(to_uppercase(·)). It misses ß≡ẞ, which APFS merges. - Leave exec deny keys stored. One grant would mean two things, fs folding and exec not, and on macOS the kernel folds exec denies anyway, so guard and kernel would disagree on one rule.
RealPath::within(dir, Identity). It lets exec choose an identity. Polarity is the caller’s statement of which way a rule speaks; identity ispath’s (grants-judge-objects).
Consequences
- macOS renders nothing differently. Seatbelt matches every rule under canonical caseless equivalence, absent and existing names alike, on every volume. On APFS that is all a rendered deny needs: the classes the key adds are distinct objects there.
- Linux and Windows hold an existing deny under every spelling already. A bwrap mask is mounted over whatever object the kernel’s lookup finds, under the volume’s own identity; Landlock rules are by descriptor; a Windows ACE attaches to an object.
- An absent deny reaches no Linux or Windows kernel, a mount or an ACE
needing an object, so for a spawned child only macOS holds it. A rendering
that materialised an object would inherit the volume’s identity, which is
exactly enough; one that refused by name would have to refuse the whole
collision_keyclass under the covering write prefix. - Over-deny, accepted. On a case-sensitive volume — Linux, case-sensitive
APFS, a case-sensitive NTFS folder — a deny on
Secretsalso refuses the distinctsecretsin process, where the Linux and Windows kernels would not; and on every volume but a compatibility-normalised ZFS one, a deny onsecretsalso refuses the distinctsecrets. That is an over-approximation in the guard, not a weakness in either enforcer. The denial says why. - Residuals, stated. On a case-sensitive APFS volume Seatbelt’s allows
also admit case variants for a spawned child, which SBPL cannot express
otherwise. The Windows allow side keeps its ASCII fold, so the guard
over-grants across ASCII case in a case-sensitive folder while the ACL is
exact; making the Windows walk spell stored names is its own change. On
Linux an exec deny inside an admitted dir never reaches the kernel, so a
child is not refused the distinct
/x/evil/tthe guard refuses under a deny on/x/Evil. On an OpenZFS volume under macOS, Seatbelt’s canonical fold lets a child create an absent denied name spelled with ı or a compatibility form. HFS+ is not in play: its case-insensitive compare skips sixteen invisible format characters (ZWJ, bidi marks, BOM) and pairs Georgian Asomtavruli with Mkhedruli, neither of which the key folds.