zeroclaw-labs/zeroclaw · error

Path '{}' resolves outside the workspace directory

Error message

Path '{}' resolves outside the workspace directory

What it means

resolve_working_dir canonicalizes the caller-supplied working directory and requires the result to be a prefix-descendant of the tool's workspace_dir (git_operations.rs:94-100). If the canonicalized path does not start with the canonical workspace root, the request is rejected — this stops both literal ../ traversal and symlink escapes, because canonicalize resolves symlinks before the comparison.

Source

Thrown at crates/zeroclaw-tools/src/git_operations.rs:99

                let resolved = candidate.canonicalize().map_err(|e| {
                    ::zeroclaw_log::record!(
                        WARN,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                            .with_attrs(::serde_json::json!({
                                "path": p,
                                "error": format!("{}", e),
                            })),
                        "git_operations: cannot resolve path"
                    );
                    anyhow::Error::msg(format!("Cannot resolve path '{}': {}", p, e))
                })?;
                let workspace_canonical = self
                    .workspace_dir
                    .canonicalize()
                    .unwrap_or_else(|_| self.workspace_dir.clone());
                if !resolved.starts_with(&workspace_canonical) {
                    anyhow::bail!("Path '{}' resolves outside the workspace directory", p);
                }
                resolved
            }
            _ => self.workspace_dir.clone(),
        };
        Ok(base)
    }

    fn candidate_path(&self, raw_path: &str) -> anyhow::Result<PathBuf> {
        if raw_path.contains('\0') {
            anyhow::bail!("Path not allowed: contains null byte");
        }
        if Path::new(raw_path)
            .components()
            .any(|c| matches!(c, std::path::Component::ParentDir))
        {
            anyhow::bail!("Path not allowed: parent-directory traversal is not allowed");
        }

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Use a path relative to the workspace root (e.g. "crates/tools") instead of ../ or absolute paths.
  2. If the target must be usable, reconstruct GitOperationsTool::new with workspace_dir set to a legitimate common ancestor containing it.
  3. If the escape is via a symlink inside the workspace, remove the symlink or place the real directory under the workspace.
  4. Pre-check on the caller side with std::fs::canonicalize and starts_with(workspace_dir) before invoking the tool.

Example fix

// before
git_execute(op: "status", working_dir: "../other-project")
// -> Path '../other-project' resolves outside the workspace directory

// after
git_execute(op: "status", working_dir: "other-project")
// where other-project is a subdirectory of the configured workspace
Defensive patterns

Strategy: validation

Validate before calling

// Reproduce the tool's check before invoking.
fn resolves_inside_workspace(raw: Option<&str>, workspace: &std::path::Path) -> std::io::Result<bool> {
    let Some(p) = raw.filter(|s| !s.is_empty()) else { return Ok(true); };
    let cand = if std::path::Path::new(p).is_absolute() {
        std::path::PathBuf::from(p)
    } else {
        workspace.join(p)
    };
    let resolved = cand.canonicalize()?;
    let ws = workspace.canonicalize().unwrap_or_else(|_| workspace.to_path_buf());
    Ok(resolved.starts_with(&ws))
}
if !resolves_inside_workspace(working_dir, &workspace)? { /* fix the path or abort */ }

Type guard

fn is_workspace_relative(p: &str) -> bool {
    !p.contains("..") && !std::path::Path::new(p).is_absolute()
}

Try / catch

match git_tool.execute(params).await {
    Err(e) if e.to_string().contains("resolves outside the workspace directory") => {
        // recompute the path relative to the workspace root (strip the
        // common prefix) and retry once; a second failure means the target
        // is genuinely out of scope — do not loop
    }
    r => r,
}

Prevention

When it happens

Trigger: Passing working_dir="../sibling-repo" or any absolute path outside the workspace to the git tool's execute; passing a path inside the workspace that is a symlink whose target lives outside (canonicalize returns the outer target, which fails starts_with); passing a path whose parent symlink points into /tmp or /home.

Common situations: Agents that know a sibling checkout exists and try to operate on it; configs where workspace_dir was set to a subdirectory while the caller assumes the parent; workspaces containing convenience symlinks (shared caches, node_modules-style links) that escape the root; absolute paths pasted from a terminal.

Related errors


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