Hmbown/CodeWhale · error
bundle path escapes the config directory via a symlink…
Error message
bundle path {candidate:?} escapes the config directory via a symlink; refused What it means
The resolved path of a bundle entry, after canonicalizing the deepest existing component, does not lie under the canonicalized config directory — meaning a symlink inside the config directory points outside it. `resolve_bounded_path` rejects this to stop symlink-based escapes from the config directory.
Solutions
- Remove or replace the escaping symlink inside the config directory so the target resolves within it.
- Point the bundle path at a real file inside the config directory instead of through the symlink.
- If the external location is intentional, copy the content into the config directory rather than symlinking it.
Example fix
// before (inside ~/.codewhale) settings -> /mnt/external/settings // after cp /mnt/external/settings ~/.codewhale/settings && rm settings
Defensive patterns
Strategy: validation
Validate before calling
let canonical_base = base_dir.canonicalize()?;
let resolved = base_dir.join(candidate);
let resolved_existing = resolved.ancestors().find(|p| p.exists()).ok_or("missing")?.canonicalize()?;
if !resolved_existing.starts_with(&canonical_base) {
return Err("candidate resolves outside config directory".into());
} Type guard
fn stays_inside_base(base: &std::path::Path, candidate: &str) -> bool {
(|| {
let cb = base.canonicalize().ok()?;
let p = base.join(candidate);
let deepest = p.ancestors().find(|a| a.exists())?;
deepest.canonicalize().ok()?.starts_with(&cb).then_some(true)
})().unwrap_or(false)
} Try / catch
match resolve_bounded_path(&base_dir, candidate) {
Ok(path) => apply(path),
Err(e) if e.to_string().contains("symlink") => log::warn!("bundle path escapes via symlink: {candidate:?}"),
Err(e) => return Err(e),
} Prevention
- Avoid symlinking config subdirectories to locations outside the config directory.
- Copy external content into the config directory instead of symlinking it.
- Audit shared config directories for unexpected symlinks before importing bundles.
When it happens
Trigger: Importing a bundle whose candidate path traverses a symlink inside the config directory that targets a location outside it; calling `resolve_bounded_path` where `resolved.starts_with(canonical_base)` fails.
Common situations: A user symlinked a config subdirectory to an external location (e.g. dotfiles managed with symlinks) and then imported a bundle touching it; an attacker-planted symlink in a shared config directory.
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
- audited skill path does not match owned package
- owned skill root escapes anchor
- built-in plugin path may not be a symbolic link or reparse…
- bundle path is absolute; only paths inside the config…
- bundle path contains a NUL byte; refused
AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22).
Data as JSON: /api/errors/4a77dd1587620800.
Report an issue: GitHub.
Appendix: source
Thrown at crates/cli/src/config_bundles.rs:741
.canonicalize()
.with_context(|| format!("config directory {} is unavailable", base_dir.display()))?;
let joined = base_dir.join(candidate_path);
// Walk the joined path's ancestors from the deepest existing component up:
// every existing component must canonicalize inside the base, so a symlink
// pointing outside the config directory is refused even when the final
// target does not exist yet.
let deepest_existing = joined
.ancestors()
.find(|ancestor| ancestor.symlink_metadata().is_ok())
.context("bundle path has no existing ancestor inside the config directory")?;
let resolved = deepest_existing.canonicalize().with_context(|| {
format!(
"could not resolve bundle path component {}",
deepest_existing.display()
)
})?;
if !resolved.starts_with(&canonical_base) {
bail!("bundle path {candidate:?} escapes the config directory via a symlink; refused");
}
Ok(joined)
}
// ---------------------------------------------------------------------------
// Remote fetch
// ---------------------------------------------------------------------------
/// Fetch a bundle over HTTPS (or plain http on loopback only) with a hard
/// size cap, a timeout, and bounded redirects. Mirrors the skill installer's
/// fetch bounds.
pub fn fetch_bundle(url: &str) -> Result<Vec<u8>> {
let mut current_url = reqwest::Url::parse(url).map_err(|_| anyhow!("invalid bundle URL"))?;
validate_bundle_url(¤t_url)?;
let initial_scheme = current_url.scheme().to_string();
let client = codewhale_release::platform_blocking_http_client_builder()
.timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))View on GitHub (pinned to 73e0f67d83)