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
- Make the real file physically reside under `workspace_dir` (copy or move it there) instead of symlinking from outside.
- 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.
- 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.
- 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
- Never send attachments through symlinks that cross the workspace boundary; materialize real files inside workspace_dir.
- In deployment docs, declare workspace_dir as the single attachment root and forbid symlinked asset dirs.
- Add a harness test that creates an escaping symlink and asserts the send fails closed (the repo already has such tests for resolve_local_attachment_path).
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
- attachment path {} escapes workspace {}
- Slack outbound attachment path escapes workspace: {}
- refusing non-HTTPS attachment URL: {url}
- attachment path not found: {}
- Key file path is a symlink — refusing to write
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/3b1be5ec89d8afc2.
Report an issue: GitHub.