zeroclaw-labs/zeroclaw · error

Key file path is a symlink — refusing to read

Error message

Key file path is a symlink — refusing to read

What it means

Key material is opened with O_NOFOLLOW on Unix so the kernel refuses to traverse a symlink for the final path component. ELOOP surfaces as InvalidInput with 'Key file path is a symlink — refusing to read'. The guard prevents symlink-swap attacks where an attacker substitutes a key file to leak or misdirect secret material.

Source

Thrown at crates/zeroclaw-config/src/secrets.rs:510

/// Open `key_path` with platform no-follow / reparse-point semantics so that
/// validation and reading are bound to the *same* object — no check-then-follow
/// window.
// NOTE: do NOT add a module-level `use std::io::Read;` — production code already
// has a function-scoped `use std::io::Read;` (resolve_onepassword_ref); a second
// module-level import triggers clippy `redundant_import` under `-D warnings`.
#[cfg(unix)]
fn open_no_follow(key_path: &Path) -> std::io::Result<std::fs::File> {
    use std::os::unix::fs::OpenOptionsExt;
    // O_NOFOLLOW: if the final path component is a symlink, open() fails with
    // ELOOP.  This binds "not a symlink" to the returned fd atomically.
    std::fs::OpenOptions::new()
        .read(true)
        .custom_flags(libc::O_NOFOLLOW)
        .open(key_path)
        .map_err(|e| {
            if e.raw_os_error() == Some(libc::ELOOP) {
                std::io::Error::new(
                    std::io::ErrorKind::InvalidInput,
                    "Key file path is a symlink — refusing to read",
                )
            } else {
                e
            }
        })
}

#[cfg(windows)]
fn open_no_follow(key_path: &Path) -> std::io::Result<std::fs::File> {
    use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
    // FILE_FLAG_OPEN_REPARSE_POINT (0x0020_0000): open the reparse point itself
    // instead of following it.  FILE_FLAG_BACKUP_SEMANTICS (0x0200_0000) lets the
    // call also work if the entry is a directory reparse point.
    const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
    const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Replace the symlink with the real file: copy the key content to the expected path so the final component is a regular file.
  2. Or change the configuration to point directly at the real file's canonical path.
  3. On containerized deployments, mount the secret file directly rather than linking to it.
  4. Do not attempt to disable the check; if the link is legitimate, resolve it in config instead.

Example fix

# before
~/.config/zeroclaw/keys/agent.key -> /etc/zeroclaw/agent.key  (symlink, refused)

# after
cp /etc/zeroclaw/agent.key ~/.config/zeroclaw/keys/agent.key  # regular file
# or point config straight at the source:
#   key_file = "/etc/zeroclaw/agent.key"
Defensive patterns

Strategy: validation

Validate before calling

fn key_file_is_regular(path: &std::path::Path) -> bool {
    std::fs::symlink_metadata(path)
        .map(|m| !m.file_type().is_symlink())
        .unwrap_or(false)
}

Try / catch

match secrets::read_key_file_no_follow(path) {
    Err(e) if e.kind() == std::io::ErrorKind::InvalidInput && e.to_string().contains("symlink") => {
        // resolve to the real file in config; never delete the guard
        Err(anyhow!("key {path} is a symlink; point key_file at the real file"))
    }
    other => other,
}

Prevention

When it happens

Trigger: provisioning_state or read_key_file_no_follow runs against a key path whose final component is a symlink: dotfile-manager links (stow, chezmoi), Nix store symlinks, Docker volume links, or a manually created ln -s for the key file.

Common situations: Users managing ~/.config with GNU stow, NixOS configurations linking keys from the store, container setups that symlink mounted secrets, multi-user hosts where an admin centralized keys via symlinks.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/b12896cf98eb2567. Report an issue: GitHub.