{"record":{"id":"b12896cf98eb2567","repo":"zeroclaw-labs/zeroclaw","slug":"key-file-path-is-a-symlink-refusing-to-read","errorCode":null,"errorMessage":"Key file path is a symlink — refusing to read","messagePattern":"Key file path is a symlink — refusing to read","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-config/src/secrets.rs","lineNumber":510,"sourceCode":"\n/// Open `key_path` with platform no-follow / reparse-point semantics so that\n/// validation and reading are bound to the *same* object — no check-then-follow\n/// window.\n// NOTE: do NOT add a module-level `use std::io::Read;` — production code already\n// has a function-scoped `use std::io::Read;` (resolve_onepassword_ref); a second\n// module-level import triggers clippy `redundant_import` under `-D warnings`.\n#[cfg(unix)]\nfn open_no_follow(key_path: &Path) -> std::io::Result<std::fs::File> {\n    use std::os::unix::fs::OpenOptionsExt;\n    // O_NOFOLLOW: if the final path component is a symlink, open() fails with\n    // ELOOP.  This binds \"not a symlink\" to the returned fd atomically.\n    std::fs::OpenOptions::new()\n        .read(true)\n        .custom_flags(libc::O_NOFOLLOW)\n        .open(key_path)\n        .map_err(|e| {\n            if e.raw_os_error() == Some(libc::ELOOP) {\n                std::io::Error::new(\n                    std::io::ErrorKind::InvalidInput,\n                    \"Key file path is a symlink — refusing to read\",\n                )\n            } else {\n                e\n            }\n        })\n}\n\n#[cfg(windows)]\nfn open_no_follow(key_path: &Path) -> std::io::Result<std::fs::File> {\n    use std::os::windows::fs::{MetadataExt, OpenOptionsExt};\n    // FILE_FLAG_OPEN_REPARSE_POINT (0x0020_0000): open the reparse point itself\n    // instead of following it.  FILE_FLAG_BACKUP_SEMANTICS (0x0200_0000) lets the\n    // call also work if the entry is a directory reparse point.\n    const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;\n    const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;\n","sourceCodeStart":492,"sourceCodeEnd":528,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-config/src/secrets.rs#L492-L528","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"# before\n~/.config/zeroclaw/keys/agent.key -> /etc/zeroclaw/agent.key  (symlink, refused)\n\n# after\ncp /etc/zeroclaw/agent.key ~/.config/zeroclaw/keys/agent.key  # regular file\n# or point config straight at the source:\n#   key_file = \"/etc/zeroclaw/agent.key\"","handlingStrategy":"validation","validationCode":"fn key_file_is_regular(path: &std::path::Path) -> bool {\n    std::fs::symlink_metadata(path)\n        .map(|m| !m.file_type().is_symlink())\n        .unwrap_or(false)\n}","typeGuard":null,"tryCatchPattern":"match secrets::read_key_file_no_follow(path) {\n    Err(e) if e.kind() == std::io::ErrorKind::InvalidInput && e.to_string().contains(\"symlink\") => {\n        // resolve to the real file in config; never delete the guard\n        Err(anyhow!(\"key {path} is a symlink; point key_file at the real file\"))\n    }\n    other => other,\n}","preventionTips":["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."],"tags":["secrets","key-file","symlink","security","unix"],"backgroundTag":"key-file-symlink-refused","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}