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
- Replace the symlink with the real file: copy the key content to the expected path so the final component is a regular file.
- Or change the configuration to point directly at the real file's canonical path.
- On containerized deployments, mount the secret file directly rather than linking to it.
- 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
- Store keys as regular files in a dedicated directory; exclude that directory from dotfile managers.
- In containers, mount secret files directly rather than linking them into the config tree.
- Add a startup check: symlink_metadata on every configured key path, fail fast with a clear message.
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
- Key file path is a symlink — refusing to write
- Key file path is a reparse point — refusing to read
- attachment path {} canonicalizes to {} which escapes workspa
- local IPC endpoint lock directory {} is owned by uid {}; it
- local IPC endpoint lock directory {} is writable by other us
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/b12896cf98eb2567.
Report an issue: GitHub.