zeroclaw-labs/zeroclaw · error · anyhow::Error
attachment path {} escapes workspace {}
Error message
attachment path {} escapes workspace {} What it means
`resolve_local_attachment_path` confines WeChat attachment targets to the channel's `workspace_dir`. This bail fires when an absolute target path, after lexical normalization (`normalize_lexical`), does not start with the normalized workspace root — the same message is also attached (via `with_context`) when `resolve_under` rejects `..` traversal in `/workspace/...` or relative targets. It is the first, lexical stage of the attachment sandbox; the symlink stage is `canonicalize_within_workspace`.
Source
Thrown at crates/zeroclaw-channels/src/wechat.rs:1113
return Self::canonicalize_within_workspace(&resolved, workspace_dir, target);
}
// Absolute paths are allowed only if they are already inside the workspace.
let candidate = Path::new(target);
if candidate.is_absolute() {
let normalized = normalize_lexical(candidate);
if !normalized.starts_with(&workspace_normalized) {
::zeroclaw_log::record!(
WARN,
::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
.with_outcome(::zeroclaw_log::EventOutcome::Unknown),
&format!(
"attachment path {} escapes workspace {}, rejected",
target,
workspace_dir.display()
)
);
anyhow::bail!(
"attachment path {} escapes workspace {}",
target,
workspace_dir.display()
);
}
return Self::canonicalize_within_workspace(&normalized, workspace_dir, target);
}
// Relative paths are resolved under the workspace root.
let resolved = resolve_under(workspace_dir, target).with_context(|| {
format!(
"attachment path {} escapes workspace {}",
target,
workspace_dir.display()
)
})?;
Self::canonicalize_within_workspace(&resolved, workspace_dir, target)
}View on GitHub (pinned to 88bb9c8533)
Solutions
- Express the target relative to the workspace root (e.g. `reports/2026-08.png`) or with the `/workspace/` prefix form (`/workspace/reports/2026-08.png`).
- Copy or move the file into `workspace_dir` first, then send the in-workspace path.
- Set/verify the WeChat channel's `workspace_dir` configuration so it covers the directory holding your files.
- If the goal is to share an externally hosted file, pass an `https://` URL as the attachment target instead of a local path — remote targets skip this check.
Example fix
// before: absolute path outside the workspace
let attachment = WeChatAttachment { target: "/tmp/render.png".into(), kind: WeChatAttachmentKind::Image };
send(&channel, attachment).await; // -> attachment path /tmp/render.png escapes workspace /workspace
// after: workspace-relative target
let attachment = WeChatAttachment { target: "render.png".into(), kind: WeChatAttachmentKind::Image }; // file at /workspace/render.png
send(&channel, attachment).await; // ok Defensive patterns
Strategy: validation
Validate before calling
// reject/normalize targets before they reach the channel
fn workspace_relative(target: &str, workspace: &std::path::Path) -> Option<String> {
let t = target.trim().strip_prefix("file://").unwrap_or(target.trim());
if t.starts_with("https://") || t.starts_with("http://") {
return Some(t.to_string()); // remote targets bypass the sandbox
}
let candidate = std::path::Path::new(t);
if candidate.is_absolute() {
return candidate.strip_prefix(workspace).ok().map(|p| p.to_string_lossy().into_owned());
}
if std::path::Path::new(t).components().any(|c| matches!(c, std::path::Component::ParentDir)) {
return None; // reject `..` traversal outright
}
Some(t.to_string())
} Type guard
fn is_contained_target(target: &str, workspace: &std::path::Path) -> bool {
let t = target.trim().strip_prefix("file://").unwrap_or(target.trim());
let p = std::path::Path::new(t);
if p.is_absolute() {
let norm = normalize(p); // resolve `.`/`..` lexically
norm.starts_with(workspace)
} else {
!p.components().any(|c| matches!(c, std::path::Component::ParentDir))
}
} Try / catch
match channel.send(msg_with_attachment(target)).await {
Err(err) if err.to_string().contains("escapes workspace") => {
// log the rejected target as a policy violation; never retry it as-is
tracing::warn!(target, "attachment rejected by workspace sandbox");
return Ok(());
}
other => other?,
} Prevention
- Always emit workspace-relative attachment targets from your tooling; never absolute paths from temp dirs.
- Strip or reject `..` components at the point where targets are produced (agent tool output, message templates).
- Treat occurrences of this error as a security signal (possible prompt injection attempting file exfiltration), not as a transient failure.
- Keep workspace_dir pinned in config so absolute-path producers can validate against the same root.
When it happens
Trigger: Sending a WeChat attachment with an absolute target like `/etc/passwd` or `/home/user/secret.txt` when `workspace_dir` is `/workspace`; a relative target such as `../../etc/passwd` or a `/workspace/../../etc/passwd` form whose `..` segments resolve outside the root via `resolve_under`; a `file://` absolute path outside the workspace after the `file://` prefix is stripped. Any local (non-URL) target passed to `load_attachment_payload` that is not lexically containable in `workspace_dir`.
Common situations: Agent tooling generates absolute file paths (temp files in `/tmp`, model outputs in `$HOME`) and passes them as WeChat attachment targets; prompt-injection content tries to attach `/etc/shadow` or `~/.ssh/id_rsa`; misconfigured `workspace_dir` (defaulting somewhere unexpected) so previously-valid absolute paths are now outside it; migrating configs between machines with different home directories.
Related errors
- attachment path {} canonicalizes to {} which escapes workspa
- refusing non-HTTPS attachment URL: {url}
- attachment path not found: {}
- Lark/Feishu marker target resolves outside workspace_dir
- Slack outbound attachment path escapes workspace: {}
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/86c8309949f06cb0.
Report an issue: GitHub.