{"record":{"id":"c3beda110b623a93","repo":"zeroclaw-labs/zeroclaw","slug":"path-resolves-outside-the-workspace-directory","errorCode":null,"errorMessage":"Path '{}' resolves outside the workspace directory","messagePattern":"Path '(.+?)' resolves outside the workspace directory","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-tools/src/git_operations.rs","lineNumber":99,"sourceCode":"                let resolved = candidate.canonicalize().map_err(|e| {\n                    ::zeroclaw_log::record!(\n                        WARN,\n                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)\n                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)\n                            .with_attrs(::serde_json::json!({\n                                \"path\": p,\n                                \"error\": format!(\"{}\", e),\n                            })),\n                        \"git_operations: cannot resolve path\"\n                    );\n                    anyhow::Error::msg(format!(\"Cannot resolve path '{}': {}\", p, e))\n                })?;\n                let workspace_canonical = self\n                    .workspace_dir\n                    .canonicalize()\n                    .unwrap_or_else(|_| self.workspace_dir.clone());\n                if !resolved.starts_with(&workspace_canonical) {\n                    anyhow::bail!(\"Path '{}' resolves outside the workspace directory\", p);\n                }\n                resolved\n            }\n            _ => self.workspace_dir.clone(),\n        };\n        Ok(base)\n    }\n\n    fn candidate_path(&self, raw_path: &str) -> anyhow::Result<PathBuf> {\n        if raw_path.contains('\\0') {\n            anyhow::bail!(\"Path not allowed: contains null byte\");\n        }\n        if Path::new(raw_path)\n            .components()\n            .any(|c| matches!(c, std::path::Component::ParentDir))\n        {\n            anyhow::bail!(\"Path not allowed: parent-directory traversal is not allowed\");\n        }","sourceCodeStart":81,"sourceCodeEnd":117,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-tools/src/git_operations.rs#L81-L117","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Use a path relative to the workspace root (e.g. \"crates/tools\") instead of ../ or absolute paths.","If the target must be usable, reconstruct GitOperationsTool::new with workspace_dir set to a legitimate common ancestor containing it.","If the escape is via a symlink inside the workspace, remove the symlink or place the real directory under the workspace.","Pre-check on the caller side with std::fs::canonicalize and starts_with(workspace_dir) before invoking the tool."],"exampleFix":"// before\ngit_execute(op: \"status\", working_dir: \"../other-project\")\n// -> Path '../other-project' resolves outside the workspace directory\n\n// after\ngit_execute(op: \"status\", working_dir: \"other-project\")\n// where other-project is a subdirectory of the configured workspace","handlingStrategy":"validation","validationCode":"// Reproduce the tool's check before invoking.\nfn resolves_inside_workspace(raw: Option<&str>, workspace: &std::path::Path) -> std::io::Result<bool> {\n    let Some(p) = raw.filter(|s| !s.is_empty()) else { return Ok(true); };\n    let cand = if std::path::Path::new(p).is_absolute() {\n        std::path::PathBuf::from(p)\n    } else {\n        workspace.join(p)\n    };\n    let resolved = cand.canonicalize()?;\n    let ws = workspace.canonicalize().unwrap_or_else(|_| workspace.to_path_buf());\n    Ok(resolved.starts_with(&ws))\n}\nif !resolves_inside_workspace(working_dir, &workspace)? { /* fix the path or abort */ }","typeGuard":"fn is_workspace_relative(p: &str) -> bool {\n    !p.contains(\"..\") && !std::path::Path::new(p).is_absolute()\n}","tryCatchPattern":"match git_tool.execute(params).await {\n    Err(e) if e.to_string().contains(\"resolves outside the workspace directory\") => {\n        // recompute the path relative to the workspace root (strip the\n        // common prefix) and retry once; a second failure means the target\n        // is genuinely out of scope — do not loop\n    }\n    r => r,\n}","preventionTips":["Always pass working_dir relative to the workspace root","Avoid symlinks inside the workspace that point outside it","Construct GitOperationsTool with the widest legitimate workspace_dir up front","Never build working_dir from raw user input without normalization"],"tags":["git","path","workspace","security","symlink","traversal"],"backgroundTag":"path-traversal-blocked","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}