jdx/mise · warning · eyre::Report

refusing to follow symlink

Error message

refusing to follow symlink {} from an untrusted parent directory

What it means

This error is thrown when resolving a path inside mise's managed-file tracking encounters a symlink whose parent directory is untrusted. A symlink is only followed if the parent directory is owned by root and not group/world-writable (mode 0o022 clear); otherwise mise bails out rather than risk following an attacker-controlled symlink (TOCTOU hardening).

Solutions

  1. Replace the symlink with a real directory (rm the symlink, mkdir the path)
  2. Ensure the parent directory of the symlink is owned by root and not group/world-writable (chown root, chmod o-w,g-w)
  3. Move the managed path to a location you control and mise trusts
  4. Check for tools or scripts that create symlinks in your mise data directories

Example fix

// before
ls -la ~/.cache  # cache -> /tmp/shared-cache (symlink in user-owned parent)
// after
rm ~/.cache && mkdir ~/.cache  # real directory, error goes away
Defensive patterns

Strategy: validation

Validate before calling

import os, stat
def symlink_parent_trusted(path):
    parent = os.path.dirname(os.path.abspath(path))
    st = os.stat(parent)
    return st.st_uid == 0 and (st.st_mode & 0o022) == 0

Type guard

def is_symlink(p):
    return os.path.islink(p)

Try / catch

match managed_dir.open(path) {
    Err(e) if e.to_string().contains("untrusted parent directory") => {
        // replace symlink with real dir, then retry
    }
    r => r,
}

Prevention

When it happens

Trigger: A path component walked by open_or_create_directory_tree_inner is a symlink (AT_SYMLINK_NOFOLLOW detects S_IFLNK) and its parent directory's st_uid is not 0 or its mode has group/world write bits set.

Common situations: Running mise against a shared or user-owned cache/scratch directory where a component like tmp or cache has been replaced by a symlink; attacker-planted symlinks in a world-writable directory like /tmp; containers running as non-root with symlinks in HOME.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of jdx/mise@533346cc37 (2026-09-17). Data as JSON: /api/errors/8fd00797c8845fc8. Report an issue: GitHub.

Appendix: source

Thrown at src/system/managed_files.rs:1680

            _ => bail!("invalid managed directory path: {}", path.display()),
        })
        .collect::<Result<Vec<_>>>()?;

    let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW;
    let mut directory = open(Path::new("/"), flags, Mode::empty())?;
    let mut current = PathBuf::from("/");
    for (index, name) in components.iter().enumerate() {
        let component_path = current.join(name);
        directory = match openat(&directory, name.as_os_str(), flags, Mode::empty()) {
            Ok(directory) => directory,
            Err(open_error) => {
                let metadata = fstatat(&directory, name.as_os_str(), AtFlags::AT_SYMLINK_NOFOLLOW);
                if metadata.is_ok_and(|metadata| {
                    SFlag::from_bits_truncate(metadata.st_mode).contains(SFlag::S_IFLNK)
                }) {
                    let parent = fstat(&directory)?;
                    if parent.st_uid != 0 || parent.st_mode & 0o022 != 0 {
                        bail!(
                            "refusing to follow symlink {} from an untrusted parent directory",
                            component_path.display()
                        );
                    }
                    let target = nix::fcntl::readlinkat(&directory, name.as_os_str())?;
                    let mut resolved = if Path::new(&target).is_absolute() {
                        PathBuf::from(target)
                    } else {
                        current.join(target)
                    };
                    resolved.extend(components.iter().skip(index + 1));
                    let resolved = resolved.absolutize()?.to_path_buf();
                    if resolved == Path::new("/") {
                        bail!(
                            "refusing to resolve managed directory {} to the filesystem root",
                            path.display()
                        );
                    }

View on GitHub (pinned to 533346cc37)