Skip to main content

winx_code_agent/utils/
path.rs

1use std::io;
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5use tracing::warn;
6
7/// Security error for path validation
8#[derive(Debug)]
9pub enum PathSecurityError {
10    /// Path escapes the workspace root (path traversal attempt)
11    PathTraversal { path: PathBuf, workspace: PathBuf },
12    /// Path is a symlink pointing outside workspace
13    SymlinkEscape { path: PathBuf, target: PathBuf, workspace: PathBuf },
14    /// Failed to canonicalize path
15    CanonicalizationFailed { path: PathBuf, error: io::Error },
16}
17
18impl std::fmt::Display for PathSecurityError {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            PathSecurityError::PathTraversal { path, workspace } => {
22                write!(
23                    f,
24                    "Path traversal detected: '{}' escapes workspace '{}'. To allow paths outside \
25                     the workspace, set WINX_ALLOW_PATHS in the winx server config (read once at \
26                     startup — restart required; exporting it in a shell has no effect)",
27                    path.display(),
28                    workspace.display()
29                )
30            }
31            PathSecurityError::SymlinkEscape { path, target, workspace } => {
32                write!(
33                    f,
34                    "Symlink escape detected: '{}' points to '{}' outside workspace '{}'",
35                    path.display(),
36                    target.display(),
37                    workspace.display()
38                )
39            }
40            PathSecurityError::CanonicalizationFailed { path, error } => {
41                write!(f, "Failed to resolve path '{}': {}", path.display(), error)
42            }
43        }
44    }
45}
46
47impl std::error::Error for PathSecurityError {}
48
49/// Parse the `:`-separated `WINX_ALLOW_PATHS` value into canonical roots.
50///
51/// Entries must be absolute (relative to *what* would be ambiguous — the tools
52/// that use this run with a per-session cwd) and must exist, so that the roots
53/// are canonical and comparable with `starts_with`. Anything else is dropped
54/// with a warning: a typo'd root silently widening nothing is the safe failure.
55fn parse_allow_paths(raw: &str) -> Vec<PathBuf> {
56    raw.split(':')
57        .map(str::trim)
58        .filter(|entry| !entry.is_empty())
59        .filter_map(|entry| {
60            let candidate = Path::new(entry);
61            if !candidate.is_absolute() {
62                warn!("WINX_ALLOW_PATHS: ignoring '{entry}' (must be an absolute path)");
63                return None;
64            }
65            match candidate.canonicalize() {
66                Ok(root) => Some(root),
67                Err(e) => {
68                    warn!("WINX_ALLOW_PATHS: ignoring '{entry}' ({e})");
69                    None
70                }
71            }
72        })
73        .collect()
74}
75
76/// Extra roots the file tools may reach outside the workspace, from
77/// `WINX_ALLOW_PATHS` (`:`-separated absolute paths, same convention as
78/// `WINX_SANDBOX_RW_PATHS`).
79///
80/// Read from the environment ONCE, at first use. That is deliberate: the
81/// containment policy is set by whoever starts the server, out of band, and is
82/// not renegotiable at runtime — no tool argument and no shell command the model
83/// is talked into running can widen it mid-session.
84///
85/// `WINX_ALLOW_PATHS=/` degenerates to "unconfined" (every canonical path on
86/// unix starts with `/`), which is the intended way to turn containment off — an
87/// explicit, visible config value rather than a separate kill-switch flag.
88fn allowed_roots() -> &'static [PathBuf] {
89    static ROOTS: OnceLock<Vec<PathBuf>> = OnceLock::new();
90    ROOTS.get_or_init(|| {
91        let roots = crate::config::env_text("WINX_ALLOW_PATHS").map(|raw| parse_allow_paths(&raw));
92        let roots = roots.unwrap_or_default();
93        if !roots.is_empty() {
94            let list = roots.iter().map(|r| r.display().to_string()).collect::<Vec<_>>().join(", ");
95            warn!(
96                "WINX_ALLOW_PATHS active: the file tools may read and write outside the \
97                 workspace, under: {list}"
98            );
99        }
100        roots
101    })
102}
103
104/// Effective operator-configured roots outside the workspace. Exposed for
105/// diagnostics so `doctor` reports the same once-read policy the file tools use.
106pub fn configured_allowed_roots() -> &'static [PathBuf] {
107    allowed_roots()
108}
109
110/// Containment predicate: a resolved path is acceptable if it is inside the
111/// workspace or inside one of the operator-configured extra roots.
112fn is_contained(
113    canonical_path: &Path,
114    canonical_workspace: &Path,
115    extra_roots: &[PathBuf],
116) -> bool {
117    canonical_path.starts_with(canonical_workspace)
118        || extra_roots.iter().any(|root| canonical_path.starts_with(root))
119}
120
121/// Validates that a path is within the workspace root (or an extra root allowed
122/// via `WINX_ALLOW_PATHS`). Returns the canonicalized path if valid.
123///
124/// # Security
125/// - Prevents path traversal attacks (../)
126/// - Detects symlinks pointing outside the allowed roots
127/// - Canonicalizes path before comparison
128pub fn validate_path_in_workspace(
129    path: &Path,
130    workspace_root: &Path,
131) -> Result<PathBuf, PathSecurityError> {
132    validate_path_with_roots(path, workspace_root, allowed_roots())
133}
134
135/// The real implementation, with the allowed roots injected so tests can drive
136/// every case without touching process-global environment state.
137pub(crate) fn validate_path_with_roots(
138    path: &Path,
139    workspace_root: &Path,
140    extra_roots: &[PathBuf],
141) -> Result<PathBuf, PathSecurityError> {
142    // Resolve the workspace boundary once, up front; everything is checked
143    // against this. Fail closed if the workspace itself can't be canonicalized.
144    let canonical_workspace = workspace_root.canonicalize().map_err(|e| {
145        PathSecurityError::CanonicalizationFailed { path: workspace_root.to_path_buf(), error: e }
146    })?;
147
148    // If `path` itself is a symlink, resolve its target and reject if it escapes.
149    // Fail CLOSED: a target we can't resolve (e.g. dangling) is refused, not
150    // waved through (the old code silently passed on any resolution failure).
151    if let Ok(metadata) = std::fs::symlink_metadata(path) {
152        if metadata.file_type().is_symlink() {
153            let target = std::fs::read_link(path).map_err(|e| {
154                PathSecurityError::CanonicalizationFailed { path: path.to_path_buf(), error: e }
155            })?;
156            let absolute_target = if target.is_absolute() {
157                target
158            } else {
159                path.parent().unwrap_or(Path::new("/")).join(&target)
160            };
161            let canonical_target = absolute_target.canonicalize().map_err(|e| {
162                PathSecurityError::CanonicalizationFailed {
163                    path: absolute_target.clone(),
164                    error: e,
165                }
166            })?;
167            if !is_contained(&canonical_target, &canonical_workspace, extra_roots) {
168                return Err(PathSecurityError::SymlinkEscape {
169                    path: path.to_path_buf(),
170                    target: canonical_target,
171                    workspace: canonical_workspace,
172                });
173            }
174        }
175    }
176
177    // Resolve `path`. If it exists, canonicalize() collapses `..`, resolves
178    // symlinks, etc. If it doesn't (creating a new file/dir), fall back to a
179    // lexical resolution of the not-yet-existing tail.
180    match path.canonicalize() {
181        Ok(canonical_path) => {
182            if is_contained(&canonical_path, &canonical_workspace, extra_roots) {
183                Ok(canonical_path)
184            } else {
185                Err(PathSecurityError::PathTraversal {
186                    path: path.to_path_buf(),
187                    workspace: canonical_workspace,
188                })
189            }
190        }
191        Err(e) if e.kind() == io::ErrorKind::NotFound => {
192            resolve_new_path(path, &canonical_workspace, extra_roots)
193        }
194        Err(e) => {
195            Err(PathSecurityError::CanonicalizationFailed { path: path.to_path_buf(), error: e })
196        }
197    }
198}
199
200/// Resolve a not-yet-existing path for containment checking (supports creating
201/// files in directories that don't exist yet — i.e. `mkdir -p` semantics).
202///
203/// Strategy: walk up to the first ancestor that exists *as a filesystem entry*,
204/// canonicalize it (resolving any real symlinks in the existing prefix), then
205/// re-apply the remaining (non-existent) components purely lexically — `..`
206/// pops, `.` is skipped — and verify the result is inside `canonical_workspace`.
207///
208/// Two subtleties make this safe:
209/// - We stop the walk-up on `symlink_metadata` (entry exists), NOT `exists()`
210///   (which follows symlinks). A dangling symlink in an intermediate component
211///   therefore becomes the canonicalize target and fails closed, instead of
212///   being treated as a fresh lexical component that a later `create_dir_all`
213///   would follow out of the workspace.
214/// - The lexical pass resolves `..` before the containment check, so
215///   `workspace/new/../../etc/passwd` is rejected.
216fn resolve_new_path(
217    path: &Path,
218    canonical_workspace: &Path,
219    extra_roots: &[PathBuf],
220) -> Result<PathBuf, PathSecurityError> {
221    let traversal = || PathSecurityError::PathTraversal {
222        path: path.to_path_buf(),
223        workspace: canonical_workspace.to_path_buf(),
224    };
225
226    // Walk up to the first ancestor that exists as a filesystem entry.
227    let mut existing = path;
228    loop {
229        if std::fs::symlink_metadata(existing).is_ok() {
230            break;
231        }
232        match existing.parent() {
233            Some(parent) => existing = parent,
234            None => break,
235        }
236    }
237
238    // The deepest existing ancestor must resolve (a dangling symlink here fails
239    // closed) and anchor the resolution.
240    let canonical_base = existing.canonicalize().map_err(|e| {
241        PathSecurityError::CanonicalizationFailed { path: existing.to_path_buf(), error: e }
242    })?;
243
244    // Apply the components after `existing` lexically (they don't exist yet, so
245    // there are no symlinks among them to follow).
246    let remainder = path.strip_prefix(existing).map_err(|_| traversal())?;
247    let mut resolved = canonical_base;
248    for component in remainder.components() {
249        match component {
250            std::path::Component::Normal(c) => resolved.push(c),
251            std::path::Component::ParentDir => {
252                resolved.pop();
253            }
254            std::path::Component::CurDir => {}
255            // RootDir / Prefix must not appear in a relative remainder.
256            _ => return Err(traversal()),
257        }
258    }
259
260    if is_contained(&resolved, canonical_workspace, extra_roots) {
261        Ok(resolved)
262    } else {
263        Err(traversal())
264    }
265}
266
267/// Check if a path is a symlink without following it
268pub fn is_symlink(path: &Path) -> bool {
269    std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
270}
271
272/// Expands a path that starts with ~ to the user's home directory
273pub fn expand_user(path: &str) -> String {
274    if path.starts_with('~') {
275        // Only expand when home is known AND valid UTF-8. The old `to_str()
276        // .unwrap_or("")` mapped `~/x` to `/x` (the filesystem root!) on a
277        // non-UTF-8 $HOME — silently pointing at the wrong place. Leaving the
278        // literal `~` is the safe failure.
279        if let Some(home_str) = home::home_dir().as_deref().and_then(Path::to_str) {
280            return path.replacen('~', home_str, 1);
281        }
282    }
283    path.to_string()
284}
285
286/// Ensures a directory exists, creating it if necessary
287pub fn ensure_directory_exists(path: &Path) -> std::io::Result<()> {
288    if !path.exists() {
289        std::fs::create_dir_all(path)?;
290    }
291    Ok(())
292}
293
294/// Resolve a user-supplied path string (possibly empty, relative, or `~`) into a
295/// workspace-confined absolute path. Empty input resolves to the workspace root.
296/// Used by the read-only search/glob tools to scope and confine their root.
297pub fn resolve_in_workspace(
298    path: &str,
299    cwd: &Path,
300    workspace_root: &Path,
301) -> Result<PathBuf, PathSecurityError> {
302    resolve_in_workspace_with_roots(path, cwd, workspace_root, allowed_roots())
303}
304
305/// Deterministic variant used by security tests and helpers that need to inject
306/// the exact containment policy instead of inheriting process-global overrides.
307pub(crate) fn resolve_in_workspace_with_roots(
308    path: &str,
309    cwd: &Path,
310    workspace_root: &Path,
311    extra_roots: &[PathBuf],
312) -> Result<PathBuf, PathSecurityError> {
313    if path.trim().is_empty() {
314        return validate_path_with_roots(workspace_root, workspace_root, extra_roots);
315    }
316    let expanded = expand_user(path);
317    let candidate = if Path::new(&expanded).is_absolute() {
318        PathBuf::from(expanded)
319    } else {
320        cwd.join(expanded)
321    };
322    validate_path_with_roots(&candidate, workspace_root, extra_roots)
323}
324
325/// Match a glob against a workspace-relative path. `*`/`?` do NOT cross `/`
326/// (so `src/*.ts` matches only direct children — `**` is the recursive form,
327/// matching `find`/`bash` semantics); a bare pattern (e.g. `*.rs`) also matches
328/// the file name at any depth, giving the intuitive "all .rs files".
329pub fn glob_matches(pattern: &glob::Pattern, relative: &Path) -> bool {
330    let opts = glob::MatchOptions { require_literal_separator: true, ..glob::MatchOptions::new() };
331    if pattern.matches_path_with(relative, opts) {
332        return true;
333    }
334    relative
335        .file_name()
336        .is_some_and(|name| pattern.matches_with(name.to_string_lossy().as_ref(), opts))
337}
338
339#[cfg(test)]
340mod tests {
341    #![allow(clippy::unwrap_used)]
342    use super::*;
343    use proptest::prelude::*;
344    use std::fs;
345    use tempfile::TempDir;
346
347    proptest! {
348        #![proptest_config(ProptestConfig::with_cases(256))]
349
350        /// The containment invariant the whole file-safety story rests on: no
351        /// relative path string — however many `..`/`.`/odd segments — can make
352        /// resolve_in_workspace return Ok pointing OUTSIDE the workspace. Either it
353        /// errors, or the result is contained. (Uses the real /tmp as a stand-in
354        /// workspace so each case is read-only and cheap.)
355        #[test]
356        fn resolve_in_workspace_ok_implies_contained(
357            segments in prop::collection::vec(
358                prop_oneof![Just("..".to_string()), Just(".".to_string()), "[a-zA-Z0-9_]{1,5}"],
359                0..10,
360            )
361        ) {
362            let ws = std::env::temp_dir().canonicalize().unwrap();
363            let rel = segments.join("/");
364            if let Ok(resolved) = resolve_in_workspace_with_roots(&rel, &ws, &ws, &[]) {
365                prop_assert!(
366                    resolved.starts_with(&ws),
367                    "resolve_in_workspace({rel:?}) escaped to {resolved:?}"
368                );
369            }
370        }
371
372        /// Same invariant at the lower layer, for ANY input string (incl. absolute
373        /// paths, traversal, junk): validate_path_in_workspace must never accept a
374        /// path that escapes, and must never panic.
375        #[test]
376        fn validate_ok_implies_contained_any_input(s in ".*") {
377            let ws = std::env::temp_dir().canonicalize().unwrap();
378            if let Ok(p) = validate_path_with_roots(Path::new(&s), &ws, &[]) {
379                prop_assert!(p.starts_with(&ws), "accepted escaping path {p:?} from input {s:?}");
380            }
381        }
382    }
383
384    #[test]
385    fn expand_user_leaves_non_tilde_paths_untouched() {
386        assert_eq!(expand_user("/abs/path"), "/abs/path");
387        assert_eq!(expand_user("rel/path"), "rel/path");
388    }
389
390    #[test]
391    fn expand_user_never_maps_tilde_to_root() {
392        // Regression: a non-UTF-8 (or unknown) $HOME used to turn `~/sub` into
393        // `/sub` (the filesystem root). It must expand to a home-prefixed path or
394        // stay literal — never collapse to root.
395        let out = expand_user("~/sub");
396        assert_ne!(out, "/sub", "~/sub must not become the filesystem root");
397        if let Some(home) = home::home_dir().and_then(|h| h.to_str().map(String::from)) {
398            assert_eq!(out, format!("{home}/sub"));
399        }
400    }
401
402    #[test]
403    fn allows_existing_file_in_workspace() {
404        let ws = TempDir::new().unwrap();
405        let f = ws.path().join("a.txt");
406        fs::write(&f, "x").unwrap();
407        let v = validate_path_with_roots(&f, ws.path(), &[]).unwrap();
408        assert!(v.starts_with(ws.path().canonicalize().unwrap()));
409    }
410
411    #[test]
412    fn allows_new_file_in_nested_nonexistent_dir() {
413        // mkdir -p semantics: a deep, not-yet-existing path resolves and stays
414        // contained (validation happens before the dirs are created).
415        let ws = TempDir::new().unwrap();
416        let f = ws.path().join("new/deep/dir/file.txt");
417        let v = validate_path_with_roots(&f, ws.path(), &[]).unwrap();
418        assert!(v.starts_with(ws.path().canonicalize().unwrap()));
419        assert!(v.ends_with("new/deep/dir/file.txt"));
420    }
421
422    #[test]
423    fn rejects_traversal_via_dotdot_in_new_path() {
424        let ws = TempDir::new().unwrap();
425        let f = ws.path().join("nope/../../etc/passwd");
426        assert!(matches!(
427            validate_path_with_roots(&f, ws.path(), &[]),
428            Err(PathSecurityError::PathTraversal { .. })
429        ));
430    }
431
432    #[test]
433    fn rejects_existing_path_outside_workspace() {
434        let ws = TempDir::new().unwrap();
435        let outside = TempDir::new().unwrap();
436        let f = outside.path().join("secret.txt");
437        fs::write(&f, "s").unwrap();
438        assert!(matches!(
439            validate_path_with_roots(&f, ws.path(), &[]),
440            Err(PathSecurityError::PathTraversal { .. })
441        ));
442    }
443
444    #[cfg(unix)]
445    #[test]
446    fn rejects_symlink_escaping_workspace() {
447        use std::os::unix::fs::symlink;
448        let ws = TempDir::new().unwrap();
449        let outside = TempDir::new().unwrap();
450        let secret = outside.path().join("secret.txt");
451        fs::write(&secret, "s").unwrap();
452        let link = ws.path().join("link.txt");
453        symlink(&secret, &link).unwrap();
454        assert!(matches!(
455            validate_path_with_roots(&link, ws.path(), &[]),
456            Err(PathSecurityError::SymlinkEscape { .. })
457        ));
458    }
459
460    #[cfg(unix)]
461    #[test]
462    fn rejects_new_file_through_dangling_symlink_dir() {
463        // An intermediate component is a dangling symlink pointing outside the
464        // workspace. The walk-up must stop on it (symlink_metadata) and fail
465        // closed, NOT treat it as a fresh lexical component that a later
466        // create_dir_all would follow out of the workspace.
467        use std::os::unix::fs::symlink;
468        let ws = TempDir::new().unwrap();
469        let outside = TempDir::new().unwrap();
470        let link = ws.path().join("evil");
471        symlink(outside.path().join("nonexistent"), &link).unwrap();
472        let f = link.join("file.txt");
473        assert!(validate_path_with_roots(&f, ws.path(), &[]).is_err());
474    }
475
476    #[test]
477    fn parse_allow_paths_keeps_absolute_existing_roots_only() {
478        let existing = TempDir::new().unwrap();
479        let existing_str = existing.path().to_string_lossy().to_string();
480        let raw = format!("{existing_str}: :relative/dir:/definitely/not/here/xyz");
481        let roots = parse_allow_paths(&raw);
482        assert_eq!(roots, vec![existing.path().canonicalize().unwrap()]);
483    }
484
485    #[test]
486    fn allowed_root_permits_existing_path_outside_workspace() {
487        let ws = TempDir::new().unwrap();
488        let outside = TempDir::new().unwrap();
489        let f = outside.path().join("note.md");
490        fs::write(&f, "x").unwrap();
491        let roots = vec![outside.path().canonicalize().unwrap()];
492
493        // Without the allowlist it is a traversal; with it, it resolves.
494        assert!(validate_path_with_roots(&f, ws.path(), &[]).is_err());
495        let v = validate_path_with_roots(&f, ws.path(), &roots).unwrap();
496        assert!(v.starts_with(outside.path().canonicalize().unwrap()));
497    }
498
499    #[test]
500    fn allowed_root_permits_new_file_outside_workspace() {
501        // The `mkdir -p` path (resolve_new_path) must honour the extra roots too.
502        let ws = TempDir::new().unwrap();
503        let outside = TempDir::new().unwrap();
504        let f = outside.path().join("new/deep/file.txt");
505        let roots = vec![outside.path().canonicalize().unwrap()];
506
507        assert!(validate_path_with_roots(&f, ws.path(), &[]).is_err());
508        let v = validate_path_with_roots(&f, ws.path(), &roots).unwrap();
509        assert!(v.ends_with("new/deep/file.txt"));
510    }
511
512    #[test]
513    fn allowed_root_does_not_widen_to_its_siblings() {
514        // Allowing one root must not allow everything above or beside it.
515        let ws = TempDir::new().unwrap();
516        let allowed = TempDir::new().unwrap();
517        let other = TempDir::new().unwrap();
518        let f = other.path().join("secret.txt");
519        fs::write(&f, "s").unwrap();
520        let roots = vec![allowed.path().canonicalize().unwrap()];
521        assert!(matches!(
522            validate_path_with_roots(&f, ws.path(), &roots),
523            Err(PathSecurityError::PathTraversal { .. })
524        ));
525    }
526
527    #[cfg(unix)]
528    #[test]
529    fn root_slash_allow_path_disables_containment() {
530        // The documented way to turn containment off: every canonical unix path
531        // starts with `/`, so `WINX_ALLOW_PATHS=/` accepts anything that resolves.
532        // Asserted against a temp dir rather than a system path, and compared to
533        // the CANONICAL form: validation always returns a canonicalized path, and
534        // on macOS the system prefixes are symlinks (`/etc` -> `/private/etc`,
535        // `/var` -> `/private/var`), so a literal comparison would be wrong there.
536        let ws = TempDir::new().unwrap();
537        let outside = TempDir::new().unwrap();
538        let f = outside.path().join("anywhere.txt");
539        fs::write(&f, "x").unwrap();
540        let roots = parse_allow_paths("/");
541        assert_eq!(roots, vec![PathBuf::from("/")]);
542
543        assert!(validate_path_with_roots(&f, ws.path(), &[]).is_err());
544        let v = validate_path_with_roots(&f, ws.path(), &roots).unwrap();
545        assert_eq!(v, f.canonicalize().unwrap());
546    }
547
548    #[cfg(unix)]
549    #[test]
550    fn symlink_into_allowed_root_is_accepted() {
551        // A symlink out of the workspace is an escape only if the target is
552        // outside every allowed root.
553        use std::os::unix::fs::symlink;
554        let ws = TempDir::new().unwrap();
555        let outside = TempDir::new().unwrap();
556        let target = outside.path().join("data.txt");
557        fs::write(&target, "d").unwrap();
558        let link = ws.path().join("link.txt");
559        symlink(&target, &link).unwrap();
560        let roots = vec![outside.path().canonicalize().unwrap()];
561
562        assert!(matches!(
563            validate_path_with_roots(&link, ws.path(), &[]),
564            Err(PathSecurityError::SymlinkEscape { .. })
565        ));
566        assert!(validate_path_with_roots(&link, ws.path(), &roots).is_ok());
567    }
568
569    #[cfg(unix)]
570    #[test]
571    fn allows_new_file_through_internal_symlink_dir() {
572        // A symlink to a directory INSIDE the workspace is fine; the resolved
573        // path stays contained.
574        use std::os::unix::fs::symlink;
575        let ws = TempDir::new().unwrap();
576        let real = ws.path().join("real");
577        fs::create_dir(&real).unwrap();
578        let link = ws.path().join("link");
579        symlink(&real, &link).unwrap();
580        let f = link.join("file.txt");
581        let v = validate_path_with_roots(&f, ws.path(), &[]).unwrap();
582        assert!(v.starts_with(ws.path().canonicalize().unwrap()));
583    }
584}