Repository navigation
Refuse a LiteLLM disk-cache directory another account could write (GHSA-w8v5-vhqr-4h9v) - #226
Conversation
diskcache, LiteLLM's disk backend, unpickles any entry its sqlite index marks as pickled, and no release fixes it (GHSA-w8v5-vhqr-4h9v). So enable_cache now resolves the directory (expanding ~ and $VARS as diskcache does), creates it and any missing parents with mode 0o700, and refuses it when another account could write it: owned by another uid or world-writable, the same for cache.db and its -wal/-shm files, or a parent owned by someone other than the user or root, or world-writable without the sticky bit. Group write stays allowed (Ubuntu's umask 002 with user-private groups). Windows skips the owner and mode checks. The CLI turns the refusal into a clean exit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…n errors Review r1 (Opus) found that diskcache keeps values of 32 KB or more as pickled .val files in two levels of hex subdirectories, which the guard never looked at: a world-writable leftover subdirectory or .val file in an accepted cache was still exploitable. secure_cache_dir now walks the cache and refuses any entry owned by another uid, world-writable, or a symlink (diskcache makes none). Entries a concurrent run culls mid-walk are skipped; unreadable ones are a clean error. Also from the review: - A root-owned parent that is group-writable now needs the sticky bit: a root-owned directory's group is shared, unlike a user-private group. - The $ re-expansion check runs before anything is created. - CacheDirError is the base for every cache-directory error (empty path, not a directory, $ re-expansion, mkdir failure); only permission refusals (UnsafeCacheDirError) carry the GHSA warning. The CLI exits cleanly on any of them. - The refusal tells the operator to delete a cache another account could have written. - The round-trip test stores a value past the 32 KB inline limit, so the second check walks a real value file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Taking this PR over from session local_3f3a8929, which has been idle since 2026-10-14; I've messaged it. Review r2 (Sol) requested changes at 9c7b52d: root-owned g+w parents, the ancestor-swap race, macOS ACL writers, umask 000, and an unreadable-entry traceback. I'm fixing those next. I'll merge it on green CI and an agreeing independent review, then remove its worktree. — session local_a8447b8d ("Finish five stale PolicyBench draft PRs") |
- Open each directory relative to its already-checked parent (O_NOFOLLOW, search-only where it can't be listed), then the cache and its contents through descriptors, and compare the path with the checked directory at the end. Review r2's parent swap is now refused at every call point. - Group write is allowed only for the owner's user-private group, so a root-owned group-writable parent is refused for root too, and macOS's shared staff group no longer passes. - Refuse ACL entries letting another account write (macOS via acl_get_fd_np, Linux POSIX access and default ACLs), counting inheritable entries on the cache and its subdirectories; deny entries and the user's own pass. - Refuse a umask under which the files diskcache adds would be writable by another account, before diskcache opens the cache. - Refuse FIFOs and other non-file entries without opening them; report any error checking an entry other than a concurrent deletion as CacheDirError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- New entries on Linux may get the directory's group (grpid/bsdgroups mounts) as well as the process's, so the umask and default-ACL rules now require every group they may get to be the user's private group. - Linux NFSv4 ACLs (system.nfs4_acl, and the NFSv4.1 DACL) are read and judged: allow entries for EVERYONE@, named accounts or groups, or a shared GROUP@ are refused. ACL attributes the guard can't judge (OpenZFS NFSv4, SMB, richacl) are refused, on parents too. - Reading the umask by setting it is serialized with a lock. - Tests: the POSIX oracle now runs acl(5)'s access check for each other account instead of restating the rule; NFSv4 ACLs are checked against RFC 5661's evaluation; concurrent umask reads are pinned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- AFS grants writes by its directories' ACLs, which the mode bits don't show. The kernel's client exposes them as afs.acl and afs.yfs.* without listing them, so the guard probes those by name; OpenAFS exposes none, so a directory whose filesystem /proc/self/mountinfo gives as "afs" is refused. - An NFSv4 holder is the server's name, passed on unmapped, so in a user namespace or on an idmapped mount the user's uid can name another account. Entries naming the user's or root's uid are now refused. - process_umask reads /proc under the same lock as its fallback, so a guard whose /proc read succeeds can't read another's 0o077 stand-in. - An NFSv4 ACL must carry its names' padding and end with its entries. - The umask concurrency tests wait on the lock attempt, not a timeout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (review r5) - Probing afs.* attributes needed read permission (fs/xattr.c exempts only system.* and security.*), so it refused safe search-only parents; CI failed on both search-only tests. The probes are gone. - mountinfo lookups missed AFS where /proc was unreadable, could be misled by unescaped whitespace in mount points, skipped files mounted from AFS, and re-read the table for every directory. - Instead, fstatfs on each opened descriptor (O_PATH included) gives the filesystem: the kernel AFS client's and OpenAFS's magics on Linux, and OpenAFS's "afs" type name on macOS. Any entry, file or directory, on AFS is refused, with advice to use another filesystem; a failed fstatfs is a CacheDirError. - The umask concurrency harness now fails, rather than passes, when its waits time out, and always releases and joins its threads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… entries (review r6) Review r6 (Opus 5.5, at 451fc84) agreed with nits. This answers them. - SMB and Coda (nit 1). On Linux an SMB cache was refused only when its system.cifs_acl probe returned the server's ACL. A dialect that can't fetch one gives EOPNOTSUPP (fs/smb/client/xattr.c), which read as no ACL, while the owners and modes the client shows may be made up from mount options (Documentation/admin-guide/cifs/usage.rst) and the server decides who writes. Coda's servers enforce ACLs, and its client asks Venus rather than reading mode bits (coda_permission in fs/coda/dir.c). Both are now refused by their statfs magics: CIFS_SUPER_MAGIC and SMB2_SUPER_MAGIC (fs/smb/client/smb1ops.c, smb2ops.c), and CODA_SUPER_MAGIC (fs/coda/inode.c). macOS smbfs is unchanged. - fstatfs64 (nit 2). On 32-bit glibc, fstatfs fails with EOVERFLOW once a block or inode count passes 32 bits (sysdeps/unix/sysv/linux/fstatfs.c), which made every cache on a large filesystem a CacheDirError. Linux now calls fstatfs64 where the C library has it; f_type sits first at the same width in its struct. - Errors (nit 3). A failed fstatfs now says "can't tell which filesystem it is on (...)", and a parent's failure names that parent rather than the cache. A macOS type name that isn't UTF-8 is decoded with surrogate escapes instead of raising a UnicodeDecodeError past the guard's handlers. - The macOS differential (nit 4) took the longest mount-point prefix, which is / for /private/var, though df shows /System/Volumes/Data (a firmlink). It now takes the mount df names, looks its type up exactly, and checks f_mntonname from the same struct against it. The failure test now checks the message names the entry and the failure. - statfs_f_type's docstring now says f_type is 64 bits on x32 (nit 5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ach descriptor's mount (review r7) - The SMB comment named an EOPNOTSUPP cause no current dialect reaches: every dialect sets get_acl. What xattr.c does show is that it turns an EINVAL from the fetch into EOPNOTSUPP, which reads as no ACL. - f_type is what each filesystem's statfs sets, not the superblock's magic: SMB2 and 3 report SMB2_SUPER_MAGIC while s_magic is CIFS_SUPER_MAGIC, and OpenAFS sets f_type apart from s_magic. - fstatfs names the filesystem the opened entry belongs to, a bind mount included; a stacking layer (FUSE, overlayfs, eCryptfs) reports its own type. - A parent whose fstat fails is named in the error, and the macOS differential checks f_mntonname for both descriptors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comment's claims that fstatfs names the filesystem the opened entry belongs to, and that f_type is what each filesystem's statfs sets, rest on fs/statfs.c (fd_statfs -> vfs_statfs -> statfs_by_dentry), which it now names. Comment only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merge audit. Squash-merged at head Gates at that head:
Independent reviews, saved verbatim in
Round 8 agreed at 🤖 Generated with Claude Code |
Summary
Dependabot alert #1, GHSA-w8v5-vhqr-4h9v: diskcache (all releases through 5.6.3) unpickles cache entries. An attacker who can write a cache directory gets code execution in whatever process reads from that cache. diskcache has no patched release, so the alert stays open. This PR refuses the configurations where it can be exploited: a LiteLLM disk cache that another account can write, can swap out, or will be able to write once diskcache adds files to it.
How PolicyBench reaches diskcache
This was read from the locked versions (diskcache 5.6.3, and litellm 1.88.6 since #225; the litellm lines cited are checked at 1.88.6).
litellm[caching]is the only package that pulls in diskcache. policyengine 6.1.2 no longer depends on it (Patch 50 of 53 Dependabot alerts in uv.lock; document diskcache and bleach, which have no fix #225).policybench/cli.pycallsenable_cache():eval-no-tools,eval-no-tools-repeatedandeval-no-tools-chunked;retry-failed-responsesandrepair-failed-rowswhen--use-cacheis passed.enable_cache()buildsCache(type="disk", disk_cache_dir=...). litellm hands that string unchanged todiskcache.Cache(disk_cache_dir)(litellm/caching/disk_cache.py:27, viacaching.py:250).diskcache.Cache.__init__runsexpanduserandexpandvarson the directory (core.py:437-438). It creates a missing directory with mode 0o755 (:447).Cache._conopens<dir>/cache.dbby path, lazily, once per thread and once per process (core.py:612-624).<hex2>/<hex2>/<name>.valunder the directory, byos.makedirsandopenat their default modes (0o777, 0o666) less the process umask (core.py:221-240). Under umask 000 that makes a world-writable.valfile in a world-writable directory (tested). SQLite'scache.db,-waland-shmstay 0o644 under umask 000 (checked with the bundled SQLite 3.53.1).Disk.fetchcallspickle.loadon any row whose stored mode isMODE_PICKLE. The pickle comes from the row itself or from that.valfile (core.py:279-284).core.py:52,:2098).The default directory,
.policybench_cachein the CWD, sits inside the operator's own checkout, so anyone who can write it can already edit the code. The residual risk isPOLICYBENCH_CACHE_DIRpointed at a directory another account created or can write, such as a pre-created path in a shared/tmp.What changes
policybench/cache.pygainssecure_cache_dir(), andenable_cache()passes its result toCache.Resolve and create the path.
~and$VARSthe way diskcache does, thenrealpath.expandvarswould turn the result into a different path.os.makedirsapplies the mode to the leaf only.Check every parent, from the root down. The guard opens
/and checks it, then opens each next directory relative to the descriptor of the one just checked, never following a symlink. Each parent must be:/tmp, where only an entry's owner can rename it), otherwise unrestricted;diskcache reopens
cache.dbby path for every new thread and process, so an account that can rename entries in a parent could swap the cache out after the check. Because each directory is opened through its already-checked parent, every check is of a directory reached only through directories no other account can change. A parent the user can search but not list (a 0o711 home, say) is opened for search only (O_SEARCHon macOS,O_PATHon Linux).Check the cache directory and everything in it, through descriptors opened the same way. The directory and every entry under it must be owned by the current uid, not world-writable, not group-writable except by the user's private group, and free of ACL entries that let another account write. That covers:
cache.dband the-wal/-shm/-journalfiles SQLite keeps beside it;.valfiles diskcache keeps large values in.diskcache makes nothing else, so a symlink, FIFO, socket or device anywhere in the cache is refused without being opened. Each entry is judged by
fstatof what was opened, and its type must match the listing's. An entry a concurrent run culls mid-walk is skipped; any other error checking an entry is aCacheDirError. At the end, the path is compared with the checked directory (samestat), which catches the user's own concurrent change.Group write is allowed only for a user-private group. Debian, Ubuntu and Fedora give each user a primary group of their own name with no other members, and their umask 002 makes every file group-writable.
user_private_gid()accepts a group as private only when it is the user's primary group, has the user's name, lists no other member, and is no other local account's primary group. macOS'sstaff,wheeland team groups are shared, so group write by them is refused, including on a root-owned parent when the cache is root's.ACLs. The mode bits don't show ACL grants, so the guard reads them:
macOS:
acl_get_fd_np(which works on search-only descriptors too) andacl_to_text. An allow entry for anyone but the user and root counts as a write when it grants any right besides read, execute, readattr, writeattr, readextattr, writeextattr, readsecurity and synchronize; an unknown right counts. Deny entries pass (macOS putsgroup:everyone deny deleteon every home directory).only_inheritentries don't apply to the directory they're on, so they count only on the cache and its subdirectories, where diskcache adds entries.Linux, POSIX ACLs: the
system.posix_acl_accessandsystem.posix_acl_defaultattributes, read through/proc/self/fdfor anO_PATHdescriptor, sincefgetxattrfails on one (open(2)). In the access ACL, named users and groups count, limited by the mask; the owner, owning-group (shown as the mask) and other rights are the mode bits steps 2 and 3 judge (acl(5)). A directory's default ACL becomes the access ACL of entries made in it, in place of the umask (acl(5)), so on the cache and its subdirectories the default ACL's other and owning-group entries count too, the owning group as each group new entries may get (step 6).Linux, NFSv4 ACLs (review r3 finding 2): an NFS mount can show 0o755 and 0o644 while its ACL lets a named account write. Linux's NFS client shows the ACL as
system.nfs4_acl, and an NFSv4.1 server may offer it only assystem.nfs4_dacl, the same entry list after a flag word (fs/nfs/nfs4proc.c; RFC 5661 sections 6.2.2 and 6.4.3.2). The guard parses both (XDR, RFC 7531), and an attribute whose names lack their padding, or that has bytes after its last entry, is unreadable and refused (RFC 4506 section 3; review r4's nit). Only allow entries count, so passing over deny entries errs toward refusing. An allow entry counts as a write when it grants any right besides read or list, read named attributes, execute, read attributes, write attributes (times and the hidden and system flags), read the ACL and synchronize (RFC 5661 section 6.2.1.3.1); an unknown right counts.OWNER@is the owner the mode checks judge.GROUP@may write only for the owner's private group.EVERYONE@and any named account or group are refused, even one named by the user's own uid or root's (review r4 finding 2). A holder is the server's name for an account, which the client passes on unmapped (nfs4proc.c), whilestatmaps the owner into the caller's user namespace and through an idmapped mount, so there the user's uid can name another account. Inherit-only entries count only on the cache and its subdirectories, and an inheritedGROUP@write needs every group new entries may get to be private.Linux, ACLs the guard can't judge (review r3 finding 2): any
system.orsecurity.attribute withaclorntsdin its name that isn't one of the above, such assystem.richaclor Samba'ssecurity.NTACL, is refused. So are OpenZFS's NFSv4 ACLs (system.nfs4_acl_xdr,security.nfs4acl_xdr) and SMB's security descriptors (system.cifs_acl,system.smb3_acl), which are probed by name, since they may not be listed. Parents are checked the same way. Onlysystem.andsecurity.names are read, because the kernel lets a process read those on an entry it can search but not read; any other name needs read permission (xattr_permissioninfs/xattr.c), so probing it would refuse a safe search-only parent (review r5 finding 4).AFS, on Linux and macOS (review r4 finding 1, review r5 findings 1–3): AFS lets an account write a file when its directory's ACL gives it write and the file's owner-write bit is set, whatever the group and other bits say (
afs_permissioninfs/afs/security.c). OpenAFS's Linux client shows no ACL attribute at all: itssrc/afs/LINUX/osi_vnodeops.chas no xattr handlers. So every entry the guard opens is refused when it is on AFS:/and each parent, the cache directory, and each subdirectory and file in it. The guard callsfstatfson the descriptor it opened, which names the filesystem the opened entry belongs to, a bind mount included (fs/statfs.c), so a file mounted from AFS into a local cache is caught, and it works on Linux'sO_PATHdescriptors (open(2), since Linux 3.12). The filesystem check reads nothing from/proc, and it is one system call per entry.f_typewith the kernel AFS client'sAFS_FS_MAGIC, 0x6B414653 (fs/afs/super.csets it ass_magic, whichstatfsreturns), and with the 0x5346414F that OpenAFS hardcodes (src/afs/LINUX/osi_vfsops.c), whichinclude/uapi/linux/magic.hnamesAFS_SUPER_MAGIC.f_typeis the struct's first field: along, but 64 bits on x32, whoselonghas 32 (glibc's x86bits/typesizes.h), and anunsigned inton s390x (glibc's and musl'sbits/statfs.h; review r6 nit 5). The magics fit in 32 bits, so the guard reads the field's low four bytes for the machine's byte order and width.fstatfs64where the C library has it. On 32-bit glibc,fstatfsfails with EOVERFLOW once a block or inode count passes 32 bits (sysdeps/unix/sysv/linux/fstatfs.c), which would make every cache on a large filesystem aCacheDirError, andfstatfs64doesn't. Its struct putsf_typefirst at the same width (glibc'sbits/statfs.h, generic and s390). Elsewhere the two are one function (glibc'sfstatfs64.c, musl'ssys/statfs.h). Review r6 nit 2.f_fstypenamewithafs, the name OpenAFS registers (src/afs/DARWIN/osi_module.c). That macOS reports the registered name there is deduced: no source read here shows that copy, except OpenAFS's own on pre-Darwin-8 systems (osi_vfsops.c; review r6 nit 5). A name that isn't UTF-8 is decoded with surrogate escapes, so it is simply notafsrather than an error that escapes the guard (review r6 nit 3). The name sits at byte 72 of the 64-bit-inodestruct statfs, after twelve fixed fields (sys/mount.h), whichfstatfs$INODE64fills on Intel andfstatfson Apple silicon.fstatfscan't read can't be checked, which is aCacheDirError(review r5 finding 1). It names that entry, a parent included, and says the filesystem query failed: "can't tell which filesystem it is on (…)" (review r6 nit 3). The kernel's AFS client asks the volume's server for the counts (afs_statfsinfs/afs/super.c), so an expired token or an unreachable server shows up as that error rather than as the AFS refusal.SMB and Coda, on Linux (review r6 nit 1). An SMB server decides who may write, by its own ACLs, while Linux's client may show owners and modes it makes up from mount options such as
uid=andfile_mode=(Documentation/admin-guide/cifs/usage.rst). The server's ACL shows assystem.cifs_acl, which the guard refuses as one it can't judge. But the probe can come back empty: the client turns an EINVAL from the fetch into EOPNOTSUPP (fs/smb/client/xattr.c), which reads as no ACL, so until now such a cache could pass (review r7 nit 1: every dialect sets a fetch,get_aclinsmb1ops.candsmb2ops.c, so it isn't that a dialect can't fetch it). Coda's servers enforce protection by ACLs (Documentation/filesystems/coda.rst), and its client asks its cache manager, Venus, whether each access is allowed rather than reading the mode bits (coda_permissioninfs/coda/dir.c). Both are now refused by type, on every entry, like AFS. SMB is matched byCIFS_SUPER_MAGICover SMB1 andSMB2_SUPER_MAGICover SMB2 and 3 (fs/smb/client/smb1ops.c,smb2ops.c), and Coda byCODA_SUPER_MAGIC(fs/coda/inode.c);include/uapi/linux/magic.hdefines all three. On Linux, SMB caches were already refused whenever the probe returned an ACL, and a probe that failed was already aCacheDirError, so only the empty-probe case goes from accepted to refused. A failed probe is now refused as SMB too, since the filesystem is checked first. macOS'ssmbfsis unchanged (see Not covered).This replaces round 5's design, which looked directories up in
/proc/self/mountinfoand probedafs.*attributes. Those probes failed CI atda03bf74on both search-only-parent tests (review r5 finding 4), the lookup passed AFS where/proccouldn't be read and could be fooled by unescaped whitespace in mount points (findings 1 and 2), and files weren't looked up at all (finding 3).The umask. diskcache adds directories and
.valfiles after the guard has run, under the process umask. The guard refuses a umask that doesn't mask others' write (0o002), and one that doesn't mask group write (0o020) when a group new entries may get isn't the user's private group, judged directory by directory. New entries get the directory's group on macOS and BSD, and on Linux when the directory is setgid (open(2)). Otherwise Linux gives them the process's group, unless the filesystem is mounted withgrpid(bsdgroups), when they get the directory's (open(2); ext4's mount options). The guard can't see mount options, so on Linux it requires both groups to be private (review r3 finding 1). A test checks on each platform that a new entry gets one of the groups the guard allows for. The refusal comes before diskcache opens the cache.Linux reads the umask from
/proc/self/status. On macOS, or when that read fails, reading it means setting it: the guard sets 0o077 and restores the old value. One lock covers both routes, so no guard reads another's stand-in, including one whose/procread succeeds while another's failed (review r3 finding 3, review r4 finding 3).Windows skips the owner, mode, ACL and umask checks. It has no
os.getuidand no POSIX owners, and CPython'sos.statreports every writable directory there as 0o777 (attributes_to_modeinPython/fileutils.c, read for 3.12). The world-writable check would refuse everything.The resolved path goes to
Cache. Re-pointing a symlink after the check can't redirect the cache.An empty
POLICYBENCH_CACHE_DIRis rejected. Before this PR, diskcache failed on it. Without the rejection,realpath("")would quietly turn it into the CWD.Errors. Permission refusals raise
UnsafeCacheDirError. Its message names the path and the problem, cites the GHSA, and says to delete the cache if another account could have written to it (it is only a cache), or else to fix the ownership, permissions, ACLs or umask, or move the cache. An AFS, SMB or Coda refusal says the cache must be on another filesystem, such as a local disk (review r5). Other failures raise the baseCacheDirErrorwith no security warning: an empty path, a non-directory,$re-expansion, a parent that can't be written, an entry that can't be checked (its filesystem unreadable, say), or a directory that changed while being checked.cli.pyturns either into aSystemExitbefore any scenario loads or any model call.Invariants (stated and tested)
owner == current uid, no o+w bit, and no g+w bit unless the group is the user's private group. Tested over all 4,096 permission patterns (setuid, setgid and sticky included), with owners me, another user and root; the current uid a regular uid and 0; and no private group, mine, or root's.owner in {current uid, root}and either the sticky bit is set, or there's no o+w bit and no g+w bit unless the group is the owner's private group. Same exhaustive grid.acl_to_textlines, and that the new-entries rule is at least as strict. That oracle restates the rule; the runtimechmod +atests are the platform check.acl(5). The oracle runs the access check algorithmacl(5)gives, for each other account (three uids, in every subset of the shared groups), rather than restating the guard's rule (review r3's nit). The ACLs are valid ones: exactly one owner, owning-group and other entry, distinct named entries, and a mask whenever there is a named entry.acl(5)lets another account write, or the mode bits show group write for a shared group whose own entry lacks it (the mode rule's extra strictness).acl(5), applied to the access ACL new entries would get, lets another account write, for some group they may get.r8/deep-properties.log).fstatfsnames as one of them is refused, and any entry whose filesystem can't be read is not accepted. Tested for each of the six identifiers on every kind of entry.f_typelayout: for any 32-bit value and any bytes after the field,statfs_f_typereads back the value from where each of seven layouts putsf_type(Hypothesis). On the machine itself,filesystem_ofagrees withstat --file-system(Linux) or with the mountdfnames, asmountlists it (macOS), whose mount point the same struct'sf_mntonnamemust also give..valfile and its hex subdirectories.The exhaustive grids pin the mode rules as regression tests. They restate the rules rather than prove them, so the evidence comes from the filesystem tests, the interleaving tests and the mutants below.
Tests
tests/test_cache.pyhas 237 cases. Owners are faked by inode, throughos.stat,os.lstatandos.fstat, so a fake holds for descriptors and across renames.Interleavings.
_interleaverebuilds a tree and runs the guard once per file-system call it makes (open,stat,lstat,fstat,scandir), doing an action just before that call:shared, which holds Alice's cache and herstagingdirectory with Bob's world-writablecachein it. Bob renamessharedaway and putsstagingin its place. Refused at every call point, as is the untouched tree.Run against this PR's previous guard (9c7b52d), the same swap is accepted at one of its 24 call points, returning Bob's 0o777 directory. The new guard refuses it at all 43 (
swap_repro.py,swap-repro.log).Refused:
cache.db,-wal,-shm,-journal,.valfile or hex subdirectory;cache.db, and a FIFO in the cache (not opened);Cacheis built (review r2's same-run case); and the umask table (000, 002 with a shared group, 020);chmod +a: write, append, writesecurity, add_file and delete_child grants on cache entries; an inheritable grant on a subdirectory; a grant on a parent; a grant on a search-only parent; an inheritable grant on a parent reaching a cache made under it;setxattr: a named-user write grant on a.valfile; a default ACL letting others write on a subdirectory; a grant on a search-only parent, read through/proc/self/fd. On GitHub Actions these fail rather than skip if the filesystem refuses the ACL.EVERYONE@, a named account or a named group with write; an entry naming the user's own uid or root's (review r4 finding 2); a write right outside the harmless set (retention); an inheritable write on the cache;GROUP@for a shared group, directly or inherited when new entries may get one; an unreadable or truncated attribute, a name short of its padding, and bytes after the last entry (review r4's nit). The guard readssystem.nfs4_aclbefore the POSIX attributes, reads a DACL listed or not, and doesn't let a safe ACL attribute excuse an unsafe DACL.system.richacl,security.NTACLandsystem.cifs_ntsdwhen listed, and the four probed by name when not. A search-only entry whose attributes outsidesystem.andsecurity.can't be read (EACCES, asfs/xattr.cgives) is still checked and accepted, since only those two are read (review r5 finding 4).fstatfsfailure (EIO) on any of the four is a plainCacheDirError(r5 finding 1) whose message starts with that entry's path, a comma or a space after it, and says the filesystem query failed (r6 nit 3). Anfstatfailure on a parent names that parent, not the cache (r7 nit 3). The fakes are keyed by inode, so they hold however the entry is opened.fstatfsitself: on Linux and macOS,filesystem_offortmp_path, opened for reading and for search only, must equal what the platform's own tool reports:stat --file-system --format=%ton Linux. On macOS it is the typemountlists for the mount pointdf -Pnames, andf_mntonname, read from the same struct for both descriptors, must be that mount point (review r7 nit 3). Matching the longest mount-point prefix instead took/for/private/var, which is on/System/Volumes/Datathrough a firmlink, and passed only because both are APFS (review r6 nit 4). A bad descriptor raisesEBADF.statfs_f_typeis checked on seven layouts (x86_64, x32, aarch64, i686, ppc64, s390x and 32-bit ppc) over any 32-bit value and any trailing bytes (Hypothesis). With a fakefstatfs, macOS type names are read from byte 72, including one that isn't UTF-8. The guard takesfstatfs64from a C library that has it andfstatfsfrom one that doesn't, and on glibc it takes the realfstatfs64(CI only)./procreads failing (review r3 finding 3), and with the first failing and the second succeeding (review r4 finding 3), both read the real 0o000. A wait that times out fails the test, the coordination loop has a 10-second deadline, and the threads are released and joined in afinally, so a stalled thread can't make it pass (review r5's nit).Plain errors without the GHSA warning: a path diskcache would re-expand (nothing is created); a file in place of the directory; a parent that can't be written; an unreadable subdirectory; a cache that can be listed but not searched (review r2's nit); an empty path.
Accepted:
ENOTSUP);~and$VARexpansion; a symlink, with the resolved target passed toCache; a value file or subdirectory culled between listing and stat, or between stat and open.Also covered: with no
getuid(Windows), no checks run; the CLI exits with the error before any scenario loads; parser cases from realacl_to_textoutput, including an unnamed holder and an unknown right; unreadable Linux ACL attributes.Mutants. 54 of 54 are killed (
r8/mutants_r8.py,r8/mutants-r8.log). They ran one at a time on the committed head throughr8/run_mutants.sh, which records the head and a clean tree before and after. Every test runs, and each mutant's failing tests are listed under it. The 54 are round 7's 53 and one new one. In round 7 a first run stopped after 40 kills, when pytest echoed a surrogate-escaped type name as raw bytes into the runner's UTF-8 decoding; the runner now decodes with replacement (r7/mutants-r7-crashed.log). Round 7's 53 were round 6's 45, with the three AFS-identifier mutants re-anchored to the table's new layout, and 8 new ones. Round 6's 45 were round 5's 42 less the eight that tested its mountinfo lookup andafs.*probes, which are gone, and 11 new ones. Round 3's 13 are each killed by a test aimed at it:only_inheritignored;OSErrors skipped (the nit);samestat;Round 4's 17 cover round 3's fixes:
GROUP@allowed for a shared group, directly or inherited; the retention right taken as harmless; holder padding misread;Round 5's four that remain cover round 4's other fixes:
r5/padding-only-mutant.log; so it is mutated with the end check; review r5 agreed it is equivalent);/procread moved back outside the lock.The 11 new ones cover round 5's fixes:
fstatfsfailures taken as "not AFS" (finding 1);mountdifferential);f_typeread as alongon s390x, by thelong's width on little-endian machines, or from its first four bytes on big-endian ones;afs.*attributes probed again (finding 4).The 8 new ones cover round 6's nits, each killed by the tests aimed at it:
fstatfstaken beforefstatfs64(nit 2);fstatfsfailure taken as no unchecked filesystem, the failure not described as one, and a parent's failure named after the cache (nit 3).The new one covers round 7's nit 3: a parent's
fstatmoved back outside the handler, so its failure is named after the cache. It is killed by the test aimed at it, alone.Real caches on Max's machine. At
098a983athe guard accepts all three: the main checkout's.policybench_cache(1.53 s; it was 0.35 s at2e6bc689, on the same shared host), the UK-revival worktree's, and the gpt61sol onboarding cache (r8/existing-caches-check-r8.txt).Results, all at
098a983a(r8/run_evidence.sh;r8/evidence-head.txtrecords the head and a clean tree).b3c5f0e1changes only a comment incache.py, andtests/test_cache.pypasses there too (233 passed, 4 skipped):tests/test_cache.pyon macOS (Python 3.12.14): 233 passed, 4 skipped. The skips are the three Linux ACL cases set withsetxattrand the glibcfstatfs64check. The NFSv4, filesystem-type and attribute-scan tests fake the attribute and filesystem reads, so they run on macOS too. Thefstatfsdifferential runs againstdfandmount(r8/test-cache-macos.log).r8/related.log) and on GIL Python 3.14.7 (r8/related-314.log; its one warning is IPython'sreturninfinallyunder 3.14).test_supervisor's real-worker test runspolicybench runworkers that callenable_cache()on a cache undertmp_path.fstatfs,f_typeand round-trip tests under seeds 1–8: 130 passed and 4 skipped each (r8/seeds.log).b3c5f0e1: CI run 40216191216 passes (lint, app, test).pytest -m "not slow"gives 5799 passed, 88 skipped, 36 deselected (r8/ci-test-b3c5f0e1.log), as at098a983a(run 40211106234,r8/ci-test-098a983a.log). That is2e6bc689's 5798 plus the new parent test, with the same 88 skipped.2e6bc689's 5798 was451fc841's 5715 plus round 7's 18 new cache cases plus Take jupyter-book out of the paper render environment and uv.lock #227's 65 reader tests (r7/ci-test-2e6bc689.log), so every new test ran on Linux, including the glibcfstatfs64check and thestat --file-systemdifferential. The Linux ACL tests fail there rather than skip when the filesystem refuses an ACL. Paper run 40216191138 and Vercel pass too.Evidence is in
~/reviews/policybench-cache-dir-guard/r3/tor8/on Max's machine.r8/sources/holds round 8's sources: Linux'sfs/fuse/inode.c,fs/overlayfs/super.candfs/ecryptfs/super.c; gvfs'sclient/gvfsfusedaemon.canddaemon/gvfsbackendsmb.c; the READMEs of bindfs and gocryptfs; and rclone'srclone mountdoc.r7/sources/holds round 7's sources: Linux'sfs/smb/client/(cifsfs.c,smb1ops.c,smb2ops.c,smb2pdu.c,cifssmb.c,xattr.c,inode.c),fs/coda/inode.canddir.c,fs/statfs.c, andDocumentation/admin-guide/cifs/usage.rstandDocumentation/filesystems/coda.rst; and glibc'ssysdeps/unix/sysv/linux/fstatfs.c,fstatfs64.candinternal_statvfs.c.r6/sources/holds round 6's: Linux'sinclude/uapi/linux/magic.h,fs/statfs.c,fs/xattr.candfs/afs/super.c; OpenAFS'ssrc/afs/LINUX/osi_vfsops.c,src/afs/DARWIN/osi_vfsops.candosi_module.c, andafs.h; glibc's and musl'sbits/statfs.h(generic and s390x) and glibc's type sizes; and macOS'ssys/mount.h; andopen(2).r5/sources/holds round 5's (fs/afs/security.c,fs/afs/xattr.cand OpenAFS'sosi_vnodeops.camong them).Not covered
smbfs) is judged by the owners, modes and ACLs macOS shows for it. The guard doesn't check them against the server's ACL. Refusing it outright would be a new restriction on macOS network home directories (review r6 nit 1).fstatfson anO_PATHdescriptor fails there (open(2)), so a cache under a parent the user can search but not list is aCacheDirErroron those kernels (review r6 nit 2).grpid, since the guard can't see mount options;user@domainor by uid, since the guard can't map the server's names to this process's ids;EVERYONE@write a sticky parent, which the mode rule would allow;chmod. Where reading the umask means setting it, the guard sets 0o077 for that instant, under a lock its own callers share. Code outside the guard that sets the umask meanwhile isn't serialized with it.CacheDirError, as it would be for diskcache.gvfsfusedaemon.cmaps its mounts into FUSE), or through a stacking filesystem such as overlayfs or eCryptfs, shows that layer's type: FUSE, overlayfs and eCryptfs each set their ownf_type(fs/fuse/inode.c,fs/overlayfs/super.c,fs/ecryptfs/super.c). Such an entry is judged by the owners, modes and ACL attributes the layer shows, as is cloud storage mounted withrclone mount, which is FUSE too. Refusing FUSE outright would also refuse caches in bindfs or gocryptfs directories, which are FUSE as well (their READMEs; review r7 nit 2).allow_othercan report any owner. A check based on ownership can't see through that.litellm[caching].Reviews
$check moved before mkdir;CacheDirErrorsplit;.valround trip;grpidgroups (step 6), NFSv4 ACLs and ACLs the guard can't judge (step 5), the umask lock (step 6), and the POSIX oracle now runsacl(5)'s algorithm (Invariants)./procread under the umask lock (step 6), XDR padding and trailing bytes (step 5), and deterministic umask tests (Tests). da03bf7 merges main (Patch 50 of 53 Dependabot alerts in uv.lock; document diskcache and bleach, which have no fix #225, Disclose why 330 frozen households record near-zero latency and two models record none #228) without conflicts.fstatfson every opened entry (step 5): unreadable/proc(finding 1) and mount-point whitespace (finding 2) no longer matter, files mounted from AFS are caught (finding 3), and theafs.*probes that failed CI on search-only parents are gone (finding 4). The umask harness now fails on a timeout (the first nit), and the per-directory mountinfo reread is gone (the second). 451fc84 merges main (Record whether each prediction row's responses were live calls or cache replays #231) without conflicts.fstatfs64(step 5), the failure messages and macOS name decoding (step 5), the macOS differential (Tests), and the x32 width,/procand macOS-name wording. 42122a8 merges main (Take jupyter-book out of the paper render environment and uv.lock #227) without conflicts.fstatfailure now named after that parent (Tests).fs/statfs.cfor whatfstatfsnames (a comment only; no code line changes). The second, this list's wording for round 7, is fixed here.🤖 Generated with Claude Code