zeroclaw-labs/zeroclaw · error · anyhow::Error

attachment path {} canonicalizes to {} which escapes workspa

Error message

attachment path {} canonicalizes to {} which escapes workspace {}

What it means

`canonicalize_within_workspace` is the second (symlink-aware) stage of the WeChat attachment sandbox. A candidate path already passed the lexical check, but `std::fs::canonicalize` resolved a symlink component so the real path no longer starts with the canonicalized `workspace_dir`. The bail is a deliberate security control: WeChat outgoing attachments may only read files physically inside the configured workspace, preventing symlink-based sandbox escape.

Source

Thrown at crates/zeroclaw-channels/src/wechat.rs:1058

        let workspace_canon = std::fs::canonicalize(workspace_dir).with_context(|| {
            format!(
                "workspace_dir {} could not be canonicalized",
                workspace_dir.display()
            )
        })?;
        if !candidate_canon.starts_with(&workspace_canon) {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                &format!(
                    "attachment path {} canonicalizes to {} which escapes workspace {}",
                    raw_target,
                    candidate_canon.display(),
                    workspace_canon.display(),
                )
            );
            anyhow::bail!(
                "attachment path {} canonicalizes to {} which escapes workspace {}",
                raw_target,
                candidate_canon.display(),
                workspace_canon.display(),
            );
        }
        Ok(candidate_canon)
    }

    fn resolve_local_attachment_path(&self, target: &str) -> anyhow::Result<PathBuf> {
        let workspace_dir = self.workspace_dir.as_deref().ok_or_else(|| {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                "workspace directory is not configured; cannot resolve local attachment path"
            );
            anyhow::Error::msg(

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Make the real file physically reside under `workspace_dir` (copy or move it there) instead of symlinking from outside.
  2. Repoint the symlink target to a location inside the workspace, or make `workspace_dir` the common ancestor that contains both the symlink and its target.
  3. Send such files by absolute path of the real file only if that real path is itself inside the workspace; otherwise serve the file over HTTPS and pass the URL as the attachment target.
  4. If the escape is intentional in your deployment, widen `workspace_dir` in the WeChat channel configuration so the canonicalized target is within it — do not try to bypass the check.

Example fix

# before: symlink escapes the sandbox
ln -s /srv/shared/big-video.mp4 /workspace/files/big-video.mp4
# send attachment target="files/big-video.mp4" -> error 331

# after: real file inside the workspace
cp /srv/shared/big-video.mp4 /workspace/files/big-video.mp4
# send attachment target="files/big-video.mp4" -> ok
Defensive patterns

Strategy: validation

Validate before calling

// verify the resolved real path stays inside the workspace BEFORE sending
fn is_within(real: &std::path::Path, workspace: &std::path::Path) -> bool {
    let Ok(real) = real.canonicalize() else { return true }; // nonexistent targets skip the check too
    let Ok(ws) = workspace.canonicalize() else { return false };
    real.starts_with(ws)
}

let target = workspace.join("files/big-video.mp4");
assert!(is_within(&target, workspace), "symlink escape; copy the file instead");

Type guard

fn is_safe_attachment_path(resolved: &std::path::Path, workspace: &std::path::Path) -> bool {
    match (resolved.canonicalize(), workspace.canonicalize()) {
        (Ok(r), Ok(w)) => r.starts_with(w),
        (Err(_), _) => true, // not yet on disk: only the lexical rule applies
        _ => false,
    }
}

Try / catch

match channel.send(msg_with_attachment(target)).await {
    Err(err) if err.to_string().contains("which escapes workspace") => {
        // copy the real file into the workspace and retry once
        let inside = copy_into_workspace(&real_source)?;
        channel.send(msg_with_attachment(inside)).await?;
    }
    other => other?,
}

Prevention

When it happens

Trigger: Sending a WeChat message with an attachment whose target is a path inside `workspace_dir` that is itself a symlink pointing outside (e.g. `/workspace/docs` -> `/etc`), or whose path contains a symlinked directory component leading outside the workspace root. Also fires when `workspace_dir` is reconfigured between the lexical check and canonicalization so the two roots disagree. Triggered via `send` with a local file attachment that passes `resolve_local_attachment_path` lexically but fails the `candidate_canon.starts_with(&workspace_canon)` check.

Common situations: Users symlink shared asset directories (media, fonts, model files) into the workspace from elsewhere on disk; CI setups where the workspace is a symlinked artifact directory whose target sits outside; hardening tests that specifically probe symlink escapes; container mounts where `/workspace` is a bind mount and attachments point through `/mnt/data`.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/3b1be5ec89d8afc2. Report an issue: GitHub.