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

  1. 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`).
  2. Copy or move the file into `workspace_dir` first, then send the in-workspace path.
  3. Set/verify the WeChat channel's `workspace_dir` configuration so it covers the directory holding your files.
  4. 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

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


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