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

  1. Remove or replace the escaping symlink inside the config directory so the target resolves within it.
  2. Point the bundle path at a real file inside the config directory instead of through the symlink.
  3. 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

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


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(&current_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)