Skip to content

Refuse a LiteLLM disk-cache directory another account could write (GHSA-w8v5-vhqr-4h9v) - #226

Merged
MaxGhenis merged 14 commits into
mainfrom
cache-dir-ownership-guard
Oct 25, 2026
Merged

MaxGhenis merged 14 commits into
mainfrom
cache-dir-ownership-guard

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

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.py calls enable_cache():
    • for eval-no-tools, eval-no-tools-repeated and eval-no-tools-chunked;
    • for retry-failed-responses and repair-failed-rows when --use-cache is passed.
  • enable_cache() builds Cache(type="disk", disk_cache_dir=...). litellm hands that string unchanged to diskcache.Cache(disk_cache_dir) (litellm/caching/disk_cache.py:27, via caching.py:250).
  • diskcache.Cache.__init__ runs expanduser and expandvars on the directory (core.py:437-438). It creates a missing directory with mode 0o755 (:447).
  • Cache._con opens <dir>/cache.db by path, lazily, once per thread and once per process (core.py:612-624).
  • Values are pickled. A pickle of 32 KB or more is written to <hex2>/<hex2>/<name>.val under the directory, by os.makedirs and open at their default modes (0o777, 0o666) less the process umask (core.py:221-240). Under umask 000 that makes a world-writable .val file in a world-writable directory (tested). SQLite's cache.db, -wal and -shm stay 0o644 under umask 000 (checked with the bundled SQLite 3.53.1).
  • Disk.fetch calls pickle.load on any row whose stored mode is MODE_PICKLE. The pickle comes from the row itself or from that .val file (core.py:279-284).
  • The mode is read from the cache's own sqlite db, so whoever can write the directory, or anything in it, controls it.
  • diskcache culls entries once the cache passes its 1 GB size limit (core.py:52, :2098).

The default directory, .policybench_cache in the CWD, sits inside the operator's own checkout, so anyone who can write it can already edit the code. The residual risk is POLICYBENCH_CACHE_DIR pointed at a directory another account created or can write, such as a pre-created path in a shared /tmp.

What changes

policybench/cache.py gains secure_cache_dir(), and enable_cache() passes its result to Cache.

  1. Resolve and create the path.

    • Expand ~ and $VARS the way diskcache does, then realpath.
    • Refuse before creating anything if diskcache's own expandvars would turn the result into a different path.
    • Create the directory and any missing parents with mode 0o700. That holds under any umask; os.makedirs applies the mode to the leaf only.
    • Resolve again after creation, in case a symlink took the name first.
  2. 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:

    • owned by the user or by root;
    • with the sticky bit (as on /tmp, where only an entry's owner can rename it), otherwise unrestricted;
    • without it, not world-writable, and not group-writable unless the group is the owner's private group (step 4);
    • free of ACL entries that let another account write it (step 5).

    diskcache reopens cache.db by 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_SEARCH on macOS, O_PATH on Linux).

  3. 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.db and the -wal/-shm/-journal files SQLite keeps beside it;
    • the hex subdirectories and .val files 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 fstat of 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 a CacheDirError. At the end, the path is compared with the checked directory (samestat), which catches the user's own concurrent change.

  4. 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's staff, wheel and team groups are shared, so group write by them is refused, including on a root-owned parent when the cache is root's.

  5. 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) and acl_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 puts group:everyone deny delete on every home directory). only_inherit entries 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_access and system.posix_acl_default attributes, read through /proc/self/fd for an O_PATH descriptor, since fgetxattr fails 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 as system.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), while stat maps 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 inherited GROUP@ write needs every group new entries may get to be private.

    • Linux, ACLs the guard can't judge (review r3 finding 2): any system. or security. attribute with acl or ntsd in its name that isn't one of the above, such as system.richacl or Samba's security.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. Only system. and security. 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_permission in fs/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_permission in fs/afs/security.c). OpenAFS's Linux client shows no ACL attribute at all: its src/afs/LINUX/osi_vnodeops.c has 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 calls fstatfs on 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's O_PATH descriptors (open(2), since Linux 3.12). The filesystem check reads nothing from /proc, and it is one system call per entry.

      • On Linux the guard compares f_type with the kernel AFS client's AFS_FS_MAGIC, 0x6B414653 (fs/afs/super.c sets it as s_magic, which statfs returns), and with the 0x5346414F that OpenAFS hardcodes (src/afs/LINUX/osi_vfsops.c), which include/uapi/linux/magic.h names AFS_SUPER_MAGIC. f_type is the struct's first field: a long, but 64 bits on x32, whose long has 32 (glibc's x86 bits/typesizes.h), and an unsigned int on s390x (glibc's and musl's bits/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.
      • On Linux the guard calls fstatfs64 where the C library has it. On 32-bit glibc, fstatfs fails 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 a CacheDirError, and fstatfs64 doesn't. Its struct puts f_type first at the same width (glibc's bits/statfs.h, generic and s390). Elsewhere the two are one function (glibc's fstatfs64.c, musl's sys/statfs.h). Review r6 nit 2.
      • On macOS it compares f_fstypename with afs, 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 not afs rather than an error that escapes the guard (review r6 nit 3). The name sits at byte 72 of the 64-bit-inode struct statfs, after twelve fixed fields (sys/mount.h), which fstatfs$INODE64 fills on Intel and fstatfs on Apple silicon.
      • An entry whose filesystem fstatfs can't read can't be checked, which is a CacheDirError (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_statfs in fs/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= and file_mode= (Documentation/admin-guide/cifs/usage.rst). The server's ACL shows as system.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_acl in smb1ops.c and smb2ops.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_permission in fs/coda/dir.c). Both are now refused by type, on every entry, like AFS. SMB is matched by CIFS_SUPER_MAGIC over SMB1 and SMB2_SUPER_MAGIC over SMB2 and 3 (fs/smb/client/smb1ops.c, smb2ops.c), and Coda by CODA_SUPER_MAGIC (fs/coda/inode.c); include/uapi/linux/magic.h defines all three. On Linux, SMB caches were already refused whenever the probe returned an ACL, and a probe that failed was already a CacheDirError, 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's smbfs is unchanged (see Not covered).

      This replaces round 5's design, which looked directories up in /proc/self/mountinfo and probed afs.* attributes. Those probes failed CI at da03bf74 on both search-only-parent tests (review r5 finding 4), the lookup passed AFS where /proc couldn'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).

  6. The umask. diskcache adds directories and .val files 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 with grpid (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 /proc read succeeds while another's failed (review r3 finding 3, review r4 finding 3).

  7. Windows skips the owner, mode, ACL and umask checks. It has no os.getuid and no POSIX owners, and CPython's os.stat reports every writable directory there as 0o777 (attributes_to_mode in Python/fileutils.c, read for 3.12). The world-writable check would refuse everything.

  8. The resolved path goes to Cache. Re-pointing a symlink after the check can't redirect the cache.

  9. An empty POLICYBENCH_CACHE_DIR is 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 base CacheDirError with 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.py turns either into a SystemExit before any scenario loads or any model call.

Invariants (stated and tested)

  • Cache directory and every entry in it: accepted iff 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.
  • Parents: accepted iff 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.
  • Group write: never changes the verdict when the group is the owner's private group, and is always refused when it isn't (Hypothesis, arbitrary uids and modes).
  • Cache rule vs. parent rule: the cache rule is at least as strict (Hypothesis).
  • Umask: accepted iff the umask masks others' write, and group write too unless every group new entries may get is the user's private group. Tested over all 512 umasks, for BSD, setgid and Linux group semantics, with the directory's group private and shared.
  • macOS ACL rule: a problem iff some allow entry, held by neither the user nor root, that applies to the entry (or is inheritable, on the cache and its subdirectories), grants a write right. Hypothesis checks this against a second, separately written statement of the rule over generated acl_to_text lines, and that the new-entries rule is at least as strict. That oracle restates the rule; the runtime chmod +a tests are the platform check.
  • Linux POSIX ACLs against acl(5). The oracle runs the access check algorithm acl(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.
    • Access ACL: refused iff 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).
    • Default ACL: refused iff acl(5), applied to the access ACL new entries would get, lets another account write, for some group they may get.
    • The default rule is at least as strict as the access rule, and the attribute format round-trips.
  • Linux NFSv4 ACLs against RFC 5661. The oracle evaluates ACEs in order, as section 6.2.1 gives: for each write right, the first allow or deny entry naming the account decides. Named accounts and groups may be anyone, the user's and root's ids included, since NFSv4 names are the server's (review r4 finding 2). Refused iff that evaluation, with the deny entries taken out, lets another account write; so always when it does with them in. Inheritance is at least as strict, the XDR round-trips, and any cut short of the whole attribute or any bytes added after it make it unreadable.
  • Each of these held over 5,000 Hypothesis examples, and the oracles' answers came out both ways: the POSIX oracle said writable in 6,565 calls and not in 3,938, the NFSv4 one 1,748 and 8,252 (r8/deep-properties.log).
  • AFS, SMB and Coda: any entry on the path to the cache or in it whose filesystem fstatfs names 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_type layout: for any 32-bit value and any bytes after the field, statfs_f_type reads back the value from where each of seven layouts puts f_type (Hypothesis). On the machine itself, filesystem_of agrees with stat --file-system (Linux) or with the mount df names, as mount lists it (macOS), whose mount point the same struct's f_mntonname must also give.
  • No swap is accepted: review r2's parent swap, done before each of the guard's file-system calls in turn, is refused at every one of them (see below).
  • Round trip: the guard accepts what LiteLLM's real diskcache backend creates, including a real .val file 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.py has 237 cases. Owners are faked by inode, through os.stat, os.lstat and os.fstat, so a fake holds for descriptors and across renames.

  • Interleavings. _interleave rebuilds 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:

    • review r2's parent swap. Bob owns the world-writable shared, which holds Alice's cache and her staging directory with Bob's world-writable cache in it. Bob renames shared away and puts staging in its place. Refused at every call point, as is the untouched tree.
    • the user replacing the cache with a world-writable one. Refused at every call point, while the untouched tree is accepted, so the harness isn't vacuous.

    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:

    • a foreign-owned cache directory, or a foreign-owned cache.db, -wal, -shm, -journal, .val file or hex subdirectory;
    • a world-writable cache directory (0o777, 0o703, 0o1777) or entry, and a leftover world-writable hex subdirectory in a 0o755 cache;
    • a group-writable cache (0o770, 0o775, 0o2775) when the group is shared;
    • a symlinked cache.db, and a FIFO in the cache (not opened);
    • a file replaced by a directory between its listing and its opening;
    • a new directory inside a foreign parent, and a world-writable parent without the sticky bit;
    • a root-owned parent at 0o775 or 0o2775 without the sticky bit, also when running as root (review r2's case);
    • the user's own group-writable parent when the group is shared;
    • a symlink to a foreign directory;
    • an existing, fully safe 0o755 cache under umask 000, refused before Cache is built (review r2's same-run case); and the umask table (000, 002 with a shared group, 020);
    • a value subdirectory that would give new files a shared group under umask 002;
    • macOS ACLs, set with 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;
    • Linux ACLs, set with setxattr: a named-user write grant on a .val file; 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.
    • review r3's grpid case: a private process group, umask 002 and an existing 0o755 cache of a shared group, not setgid, refused with the grpid explanation; accepted once both groups are private.
    • NFSv4 ACL fixtures: 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 reads system.nfs4_acl before the POSIX attributes, reads a DACL listed or not, and doesn't let a safe ACL attribute excuse an unsafe DACL.
    • ACL attributes it can't judge: system.richacl, security.NTACL and system.cifs_ntsd when listed, and the four probed by name when not. A search-only entry whose attributes outside system. and security. can't be read (EACCES, as fs/xattr.c gives) is still checked and accepted, since only those two are read (review r5 finding 4).
    • AFS, SMB and Coda (reviews r4 to r6): each of six identifiers (AFS's two Linux magics and its macOS name, SMB's two magics and Coda's) on each of four entries: a parent, the cache directory, a value subdirectory and a value file, the last standing for a file mounted from such a filesystem into a local cache (r5 finding 3). Each is refused with the remedy, naming the filesystem. An fstatfs failure (EIO) on any of the four is a plain CacheDirError (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). An fstat failure 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.
    • fstatfs itself: on Linux and macOS, filesystem_of for tmp_path, opened for reading and for search only, must equal what the platform's own tool reports: stat --file-system --format=%t on Linux. On macOS it is the type mount lists for the mount point df -P names, and f_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/Data through a firmlink, and passed only because both are APFS (review r6 nit 4). A bad descriptor raises EBADF. statfs_f_type is 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 fake fstatfs, macOS type names are read from byte 72, including one that isn't UTF-8. The guard takes fstatfs64 from a C library that has it and fstatfs from one that doesn't, and on glibc it takes the real fstatfs64 (CI only).
    • concurrent umask reads: the first reader holds the 0o077 stand-in until the second has either finished or tried to take the lock, which a wrapped lock records, so the test doesn't depend on scheduling (review r4's nit). With both /proc reads 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 a finally, 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:

    • a fresh directory and its parents, created 0o700 under umasks 000, 002, 022 and 077 (umask 000 is then refused, with the directories already made);
    • an existing 0o755 cache, left at 0o755;
    • group-writable caches with 0o664 files when the group is the user's private group, and a cache used under umask 002 with a private group, accepted again afterwards;
    • root-owned parents, sticky parents, the user's own group-writable parent with a private group, and a parent the user can search but not list;
    • macOS deny entries and the user's own entries;
    • a Linux named-user grant whose write the mask removes;
    • an NFSv4 ACL a server derives from 0o755, deny, audit and alarm entries, write-attributes rights, and inherit-only entries on a parent;
    • a filesystem without extended attributes (ENOTSUP);
    • ~ and $VAR expansion; a symlink, with the resolved target passed to Cache; 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 real acl_to_text output, 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 through r8/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 and afs.* probes, which are gone, and 11 new ones. Round 3's 13 are each killed by a test aimed at it:

  • root exempt from the shared-group parent rule (review r2 finding 1);
  • group write by any group allowed;
  • parents checked by path after the cache is opened (finding 2). The swap test alone also kills it, at call point 27;
  • no ACL check (finding 3);
  • only_inherit ignored;
  • deny entries counted;
  • no umask check on the cache, or on its subdirectories (finding 4);
  • other OSErrors skipped (the nit);
  • the opened entry's type not compared;
  • no final samestat;
  • non-file entries opened;
  • symlinks followed.

Round 4's 17 cover round 3's fixes:

  • the Linux new-entry groups cut to the process's alone; the umask rule, or the default ACL's owning-group entry, judged for the directory's group alone; setgid ignored;
  • NFSv4 ACLs not read; the DACL not read, or read without skipping its flag word;
  • ACL attributes the guard can't judge accepted, or not probed when unlisted;
  • NFSv4 deny entries refused; inherit-only entries applied to the directory; inherited entries ignored; GROUP@ allowed for a shared group, directly or inherited; the retention right taken as harmless; holder padding misread;
  • the umask read without the lock.

Round 5's four that remain cover round 4's other fixes:

  • NFSv4 entries naming the user's or root's uid accepted again;
  • bytes after the last NFSv4 entry accepted, or neither the padding nor the end checked (dropping the padding check alone is equivalent: a name past the end leaves the read position past it too, so the next read or the end check refuses it, and that mutant survives, r5/padding-only-mutant.log; so it is mutated with the end check; review r5 agreed it is equivalent);
  • the /proc read moved back outside the lock.

The 11 new ones cover round 5's fixes:

  • each of the three AFS identifiers dropped in turn, and no filesystem check at all;
  • the filesystem checked for directories only (finding 3);
  • fstatfs failures taken as "not AFS" (finding 1);
  • macOS's type name read from the wrong offset (killed by the mount differential);
  • f_type read as a long on s390x, by the long'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:

  • SMB1's, SMB2 and 3's, or Coda's magic dropped in turn (nit 1), each failing on all four entries;
  • fstatfs taken before fstatfs64 (nit 2);
  • a macOS type name decoded strictly, an fstatfs failure 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 fstat moved 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 098a983a the guard accepts all three: the main checkout's .policybench_cache (1.53 s; it was 0.35 s at 2e6bc689, 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.txt records the head and a clean tree). b3c5f0e1 changes only a comment in cache.py, and tests/test_cache.py passes there too (233 passed, 4 skipped):

  • tests/test_cache.py on macOS (Python 3.12.14): 233 passed, 4 skipped. The skips are the three Linux ACL cases set with setxattr and the glibc fstatfs64 check. The NFSv4, filesystem-type and attribute-scan tests fake the attribute and filesystem reads, so they run on macOS too. The fstatfs differential runs against df and mount (r8/test-cache-macos.log).
  • The cache, supervisor, eval, batch, chunked, retry, CLI and call-provenance test files: 633 passed, 4 skipped, on Python 3.12.14 (r8/related.log) and on GIL Python 3.14.7 (r8/related-314.log; its one warning is IPython's return in finally under 3.14). test_supervisor's real-worker test runs policybench run workers that call enable_cache() on a cache under tmp_path.
  • The ACL, umask, NFSv4, filesystem, fstatfs, f_type and round-trip tests under seeds 1–8: 130 passed and 4 skipped each (r8/seeds.log).
  • CI on Linux at 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 at 098a983a (run 40211106234, r8/ci-test-098a983a.log). That is 2e6bc689's 5798 plus the new parent test, with the same 88 skipped. 2e6bc689's 5798 was 451fc841'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 glibc fstatfs64 check and the stat --file-system differential. 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/ to r8/ on Max's machine. r8/sources/ holds round 8's sources: Linux's fs/fuse/inode.c, fs/overlayfs/super.c and fs/ecryptfs/super.c; gvfs's client/gvfsfusedaemon.c and daemon/gvfsbackendsmb.c; the READMEs of bindfs and gocryptfs; and rclone's rclone mount doc. r7/sources/ holds round 7's sources: Linux's fs/smb/client/ (cifsfs.c, smb1ops.c, smb2ops.c, smb2pdu.c, cifssmb.c, xattr.c, inode.c), fs/coda/inode.c and dir.c, fs/statfs.c, and Documentation/admin-guide/cifs/usage.rst and Documentation/filesystems/coda.rst; and glibc's sysdeps/unix/sysv/linux/fstatfs.c, fstatfs64.c and internal_statvfs.c. r6/sources/ holds round 6's: Linux's include/uapi/linux/magic.h, fs/statfs.c, fs/xattr.c and fs/afs/super.c; OpenAFS's src/afs/LINUX/osi_vfsops.c, src/afs/DARWIN/osi_vfsops.c and osi_module.c, and afs.h; glibc's and musl's bits/statfs.h (generic and s390x) and glibc's type sizes; and macOS's sys/mount.h; and open(2). r5/sources/ holds round 5's (fs/afs/security.c, fs/afs/xattr.c and OpenAFS's osi_vnodeops.c among them).

Not covered

  • Who counts as a private group is read from the local account database. If another source (LDAP without enumeration, say) gives some other user the same primary group, the guard won't see it.
  • ACLs elsewhere. macOS ACLs, Linux POSIX ACLs and Linux's view of NFSv4 ACLs are read. AFS is refused on Linux and macOS, and SMB and Coda on Linux. On other Unixes neither ACLs nor these filesystems are checked. A client that reported some other magic or type name than the ones above wouldn't be recognized. The sources read were for the two Linux AFS clients, OpenAFS on macOS, and Linux's SMB and Coda clients.
  • SMB on macOS (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).
  • Linux before 3.12. fstatfs on an O_PATH descriptor fails there (open(2)), so a cache under a parent the user can search but not list is a CacheDirError on those kernels (review r6 nit 2).
  • What errs toward refusing. Each of these is refused though it may be safe:
    • on Linux, an existing shared-group cache under umask 002 on a filesystem without grpid, since the guard can't see mount options;
    • any NFSv4 entry that lets a named account write, even the user's own, whether by user@domain or by uid, since the guard can't map the server's names to this process's ids;
    • an NFSv4 entry letting EVERYONE@ write a sticky parent, which the mode rule would allow;
    • caches on OpenZFS with NFSv4 ACLs, and any cache with a parent or entry on AFS, or on SMB or Coda under Linux (one in an AFS or SMB home directory, say), though its ACLs may let no one else write;
    • a permissive umask under a Linux default ACL with no write for others, even though the default ACL, not the umask, then sets new entries' modes.
  • The check runs once, at startup. Another account can't gain write afterwards except through the user's own change, such as a later 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.
  • Each entry is opened read-only to check it, though nothing is read. An entry the user can't read is a CacheDirError, as it would be for diskcache.
  • Windows is unchecked (see step 7).
  • Layers over a network filesystem. AFS, SMB or Coda reached through FUSE, such as an SMB share opened in GNOME's gvfs (its SMB backend uses libsmbclient, and gvfsfusedaemon.c maps 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 own f_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 with rclone 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).
  • FUSE mounts with allow_other can report any owner. A check based on ownership can't see through that.
  • The alert stays open. Closing it takes a diskcache release that stops unpickling untrusted data, or dropping litellm[caching].

Reviews

  • r1 (Opus 5.5, in-session, adversarial, at aed5e6f) asked for changes, fixed in 9c7b52d:
    • the cache contents walk;
    • root-owned group-writable parents;
    • the $ check moved before mkdir;
    • the CacheDirError split;
    • the remedy text;
    • a real .val round trip;
    • more accurate PR wording.
  • r2 (GPT-6.1 Sol, at 9c7b52d) asked for changes: four should-fix findings and a nit. All five are fixed in cd651d2 (steps 2–6 above). The results above replace the placeholder it flagged, and the macOS ACL case it couldn't run is now a runtime test.
  • r3 (GPT-6.1 Sol, at 7b76051) asked for changes: three should-fix findings and a nit. All four are fixed in 37b5c1b: Linux grpid groups (step 6), NFSv4 ACLs and ACLs the guard can't judge (step 5), the umask lock (step 6), and the POSIX oracle now runs acl(5)'s algorithm (Invariants).
  • r4 (GPT-6.1 Sol, at 37b5c1b) asked for changes: three should-fix findings and two nits. All five are fixed in 74db0e0: AFS (step 5), NFSv4 entries naming the user's uid (step 5), the /proc read 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.
  • r5 (GPT-6.1 Sol, at da03bf7) asked for changes: four should-fix findings and two nits; all but the first nit concern round 4's AFS fix. All six are answered in 477725c by identifying the filesystem with fstatfs on 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 the afs.* 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.
  • r6 (Opus 5.5 in-session, at 451fc84; the queued Sol run was cancelled while every Subfleet lane was on operator hold) agreed with nits (AGREE_WITH_NITS) and found every round-5 finding fixed. Its five nits are all answered in 2e6bc68: SMB and Coda refused by type (step 5), fstatfs64 (step 5), the failure messages and macOS name decoding (step 5), the macOS differential (Tests), and the x32 width, /proc and macOS-name wording. 42122a8 merges main (Take jupyter-book out of the paper render environment and uv.lock #227) without conflicts.
  • r7 (Opus 5.5 in-session, at 2e6bc68) agreed with nits (AGREE_WITH_NITS) and found all five round-6 nits fixed. Its three nits are answered in 098a983: the cause of SMB's empty probe (step 5 and the code comments) and the "superblock's magic" wording (a code comment), neither of which the cited sources showed; layers over a network filesystem (Not covered); and the macOS mount check for both descriptors, with a parent's fstat failure now named after that parent (Tests).
  • r8 (Opus 5.5 in-session, at 098a983) agreed with nits (AGREE_WITH_NITS), found all three round-7 nits fixed, and said its two nits need no further round. b3c5f0e takes the first, citing fs/statfs.c for what fstatfs names (a comment only; no code line changes). The second, this list's wording for round 7, is fixed here.

🤖 Generated with Claude Code

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>
@vercel

vercel Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
policybench-site Ready Ready Preview Oct 25, 2026 5:24pm UTC

Request Review

…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>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

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")

MaxGhenis and others added 2 commits October 24, 2026 22:03
- 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>
MaxGhenis and others added 2 commits October 25, 2026 04:24
- 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>
MaxGhenis and others added 2 commits October 25, 2026 05:08
… (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>
MaxGhenis and others added 2 commits October 25, 2026 08:42
… 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>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 25, 2026 17:37
@MaxGhenis
MaxGhenis merged commit e0217ed into main Oct 25, 2026
6 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Merge audit. Squash-merged at head b3c5f0e1 (pinned with --match-head-commit), merge commit e0217ede. Its tree is identical to b3c5f0e1.

Gates at that head:

  • gh pr checks 226 exited 0, rechecked just before the merge, after the PR was marked ready (which started no new runs): CI run 40216191216 (lint, app, test: 5799 passed, 88 skipped) and Paper run 40216191138 passed, and so did Vercel.
  • mergeable was MERGEABLE and mergeStateStatus CLEAN, main (370caed9) was an ancestor of the head, and there was no CHANGES_REQUESTED review.
  • No published number moves: the change refuses unsafe cache directories and touches no scoring, data or paper output. Dependabot alert Initial version #1 stays open by design, since diskcache has no patched release.

Independent reviews, saved verbatim in ~/reviews/policybench-cache-dir-guard/:

  • r1 (Opus 5.5 in-session, at aed5e6fc): REQUEST_CHANGES, fixed in 9c7b52db.
  • r2 to r5 (GPT-6.1 Sol, at 9c7b52db, 7b760517, 37b5c1b1 and da03bf74): REQUEST_CHANGES each round, every finding fixed in the next head.
  • r6 to r8 (Opus 5.5 in-session, at 451fc841, 2e6bc689 and 098a983a, while every Subfleet lane was on operator hold): AGREE_WITH_NITS, with nothing blocking.

Round 8 agreed at 098a983a and said its two nits need no further round. b3c5f0e1 takes the first, a comment-only citation of fs/statfs.c with no code line changed, and the body takes the second. At b3c5f0e1, tests/test_cache.py passed locally (233 passed, 4 skipped), every one of the 54 mutants' anchors is unchanged, and CI passed.

🤖 Generated with Claude Code

@MaxGhenis
MaxGhenis deleted the cache-dir-ownership-guard branch October 25, 2026 17:40

This branch was successfully deployed

1 active deployment
Preview — b3c5f0e1 Deployed Oct 25, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant