diff --git a/crates/zclaw-runtime/src/tool/builtin/path_validator.rs b/crates/zclaw-runtime/src/tool/builtin/path_validator.rs index 80e93fe..82e0aec 100644 --- a/crates/zclaw-runtime/src/tool/builtin/path_validator.rs +++ b/crates/zclaw-runtime/src/tool/builtin/path_validator.rs @@ -97,6 +97,17 @@ fn default_blocked_paths() -> Vec { ] } +/// Normalize Windows UNC path prefix for consistent comparison. +/// `\\?\C:\Users\...` → `C:\Users\...` +fn normalize_windows_path(path: &Path) -> std::borrow::Cow<'_, Path> { + let s = path.to_string_lossy(); + if s.starts_with(r"\\?\") { + std::borrow::Cow::Owned(PathBuf::from(&s[4..])) + } else { + std::borrow::Cow::Borrowed(path) + } +} + /// Expand tilde in path to home directory fn expand_tilde(path: &str) -> PathBuf { if path.starts_with('~') { @@ -154,9 +165,16 @@ impl PathValidator { } } - /// Set the workspace root directory + /// Set the workspace root directory. + /// Canonicalizes the path to ensure consistent comparison on Windows + /// (where canonicalize() returns `\\?\C:\...` UNC paths). pub fn with_workspace(mut self, workspace: PathBuf) -> Self { - self.workspace_root = Some(workspace); + let canonical = if workspace.exists() { + workspace.canonicalize().unwrap_or(workspace) + } else { + workspace + }; + self.workspace_root = Some(canonical); self } @@ -287,10 +305,14 @@ impl PathValidator { Ok(()) } - /// Check if path is in blocked list + /// Check if path is in blocked list. + /// Normalizes Windows UNC prefix (`\\?\`) for consistent comparison. fn check_blocked(&self, path: &Path) -> Result<()> { + // Strip Windows UNC prefix for consistent matching + let normalized = normalize_windows_path(path); for blocked in &self.config.blocked_paths { - if path.starts_with(blocked) || path == blocked { + let blocked_norm = normalize_windows_path(blocked); + if normalized.starts_with(&*blocked_norm) || normalized == blocked_norm { return Err(ZclawError::InvalidInput(format!( "Access to this path is blocked: {}", path.display() @@ -310,11 +332,15 @@ impl PathValidator { /// - This prevents accidental exposure of the entire filesystem /// when the validator is misconfigured or used without setup fn check_allowed(&self, path: &Path) -> Result<()> { + let path_norm = normalize_windows_path(path); + // If no allowed paths specified, check workspace if self.config.allowed_paths.is_empty() { if let Some(ref workspace) = self.workspace_root { // Workspace is configured - validate path is within it - if !path.starts_with(workspace) { + // Both sides are canonicalized (workspace via with_workspace, path via resolve_and_validate) + let ws_norm = normalize_windows_path(workspace); + if !path_norm.starts_with(&*ws_norm) { return Err(ZclawError::InvalidInput(format!( "Path outside workspace: {} (workspace: {})", path.display(), @@ -336,7 +362,8 @@ impl PathValidator { // Check against allowed paths for allowed in &self.config.allowed_paths { - if path.starts_with(allowed) { + let allowed_norm = normalize_windows_path(allowed); + if path_norm.starts_with(&*allowed_norm) { return Ok(()); } }