Hmbown/CodeWhale · error · io::Error (PermissionDenied)
Codewhale-owned credential file must be singly linked
Error message
Codewhale-owned credential file must be singly linked
What it means
For Codewhale-owned files on Windows with `require_owner_only = true`, `GetFileInformationByHandle` reports the number of hard links; anything other than 1 returns `PermissionDenied`. Extra hard links mean the same file data is reachable at another path, which defeats per-file ownership/mode assumptions and enables link-swap tampering.
Solutions
- Give the file a fresh single-link inode: `Copy-Item` the file to a temp name and `Move-Item -Force` it over the original.
- Find the extra links (`fsutil hardlink list <path>` on newer Windows) and delete the aliases.
- Recreate the credential file with the owning tool instead of copying/linking it.
Example fix
# before PS> fsutil hardlink list $env:USERPROFILE\.codewhale\credentials\claude.json # shows 2+ links # after PS> Copy-Item $env:USERPROFILE\.codewhale\credentials\claude.json $env:TEMP\claude.json PS> Move-Item -Force $env:TEMP\claude.json $env:USERPROFILE\.codewhale\credentials\claude.json
Defensive patterns
Strategy: validation
Validate before calling
fn ensure_single_link(path: &std::path::Path) -> std::io::Result<()> {
use std::os::windows::io::*;
let f = std::fs::File::open(path)?;
let info = f.metadata()?;
if info.number_of_links() != 1 {
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "file must be singly linked"));
}
Ok(())
} Try / catch
match read_codewhale_owned_to_string(&path) {
Err(e) if e.to_string().contains("singly linked") => { relink_copy(&path)?; read_codewhale_owned_to_string(&path).map_err(Into::into) }
other => other.map_err(Into::into),
} Prevention
- Never hard-link credential files; always copy.
- Watch for backup/restore and dedupe tools that hard-link into the credentials directory.
- Audit with `fsutil hardlink list` after any restore operation.
When it happens
Trigger: The credential file was hard-linked (`mklink /H` or `fsutil hardlink create`) to another location, or created by tooling that hard-links (rsync `--link-dest` equivalents, copy-on-write dedupe, some backup restore tools).
Common situations: Backup-restore jobs recreating credentials via hard links; users aliasing the file into a second profile with `mklink /H`; deduplicating sync tools that link identical files.
Understand the failure class
Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.
Related errors
- Automation lock must not be a reparse point
- Codewhale credentials directory must be owned by the…
- Codewhale-owned credential file must be singly linked…
- config lock was redirected while opening
- could not inspect workspace .env link count
AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15).
Data as JSON: /api/errors/1dea00f9cab01be9.
Report an issue: GitHub.
Appendix: source
Thrown at crates/tui/src/external_credentials.rs:330
let expected = normalize_windows_path_for_comparison(path)?;
if actual != expected {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"external credential path was redirected while opening",
));
}
if require_owner_only {
use windows_sys::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
};
let mut information = BY_HANDLE_FILE_INFORMATION::default();
// SAFETY: the opened credential handle and output pointer remain valid
// for the duration of the call.
if unsafe { GetFileInformationByHandle(handle, &mut information) } == 0 {
return Err(io::Error::last_os_error());
}
if information.nNumberOfLinks != 1 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"Codewhale-owned credential file must be singly linked",
));
}
verify_windows_owner_only_handle(handle)?;
}
Ok(file)
}
/// Normalize a Windows path without replacement characters. Unpaired UTF-16
/// is rejected so two distinct paths can never compare equal after a lossy
/// conversion. This is intentionally stricter than filesystem display.
#[cfg(windows)]
fn normalize_windows_path_for_comparison(path: &Path) -> io::Result<String> {
let text = path.to_str().ok_or_else(|| {
io::Error::new(
io::ErrorKind::PermissionDenied,
"credential path contains invalid Unicode and cannot be compared safely",View on GitHub (pinned to 433685b202)