Coverage for mcp/metric_readers/localfs.py: 100%

21 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-15 15:07 +0000

1"""Confine a caller-supplied path to an allowlisted root directory. 

2 

3The local-file metric reader is the only reader that accepts a path into the 

4host filesystem, so it needs to prove — before opening anything — that the 

5path stays inside a single allowlisted root. This module owns that proof and 

6nothing else. 

7 

8:func:`resolve_within_root` is a pure function of ``(supplied_path, root)``: 

9it resolves the path with realpath semantics (collapsing ``..`` segments and 

10following every symlink), then checks the result against the resolved root. 

11A single containment test catches both attack classes — a path that walks out 

12with ``..`` segments and a path that stays lexically inside but points at a 

13symlink whose target lives elsewhere. The helper never opens or reads any 

14file, and never consults the environment; the caller reads the configured 

15root once and passes it in as ``root``. 

16""" 

17 

18from __future__ import annotations 

19 

20import os 

21from pathlib import Path 

22 

23from .shape import ErrorCode, MetricReaderError 

24 

25 

26def resolve_within_root(supplied_path: str, root: str) -> Path: 

27 """Resolve ``supplied_path`` and prove it stays within ``root``. 

28 

29 The returned path is the fully resolved location to read from, guaranteed 

30 to live inside the resolved ``root``. Resolution uses realpath semantics: 

31 ``..`` segments are collapsed and every symlink in the path is followed, 

32 so a path escapes by either route is caught by the same containment check. 

33 

34 ``root`` must be a non-empty directory path; the caller is responsible for 

35 reading it (for example from an environment variable) before calling here. 

36 An unset or empty ``root`` raises a not-configured error so the reader can 

37 refuse rather than fall back to some implicit location. 

38 

39 Raises: 

40 MetricReaderError: with code ``LOCAL_ROOT_NOT_CONFIGURED`` when 

41 ``root`` is empty; ``PATH_TRAVERSAL_ESCAPE`` when the supplied 

42 path's ``..`` segments alone walk outside the root; or 

43 ``SYMLINK_ESCAPE`` when the path stays lexically inside the root 

44 but a symlink target points outside it. The supplied path is 

45 included in the error details for the two escape cases. 

46 """ 

47 if not root: 

48 raise MetricReaderError(ErrorCode.LOCAL_ROOT_NOT_CONFIGURED) 

49 

50 # Absolute, real root: collapses ``..`` and follows any symlinks so the 

51 # containment comparison below is between two fully-resolved paths. 

52 real_root = Path(root).resolve() 

53 

54 supplied = Path(supplied_path) 

55 # An absolute supplied path is used as-is; a relative one is joined under 

56 # the root before resolution. 

57 candidate = supplied if supplied.is_absolute() else (real_root / supplied) 

58 resolved = candidate.resolve() 

59 

60 if not resolved.is_relative_to(real_root): 

61 # The path escaped. Decide which distinct code to surface by asking 

62 # whether ``..`` segments alone — collapsed lexically, without 

63 # following any symlink — would already leave the root. If they would, 

64 # this is a traversal escape; otherwise the lexical path stayed inside 

65 # and only a symlink target jumped out. 

66 if _lexically_escapes(supplied, real_root): 

67 raise MetricReaderError( 

68 ErrorCode.PATH_TRAVERSAL_ESCAPE, 

69 {"supplied_path": supplied_path}, 

70 ) 

71 raise MetricReaderError( 

72 ErrorCode.SYMLINK_ESCAPE, 

73 {"supplied_path": supplied_path}, 

74 ) 

75 

76 return resolved 

77 

78 

79def _lexically_escapes(supplied: Path, real_root: Path) -> bool: 

80 """Return True when collapsing ``..`` alone walks ``supplied`` out of root. 

81 

82 This mirrors the realpath collapse but stops short of touching the 

83 filesystem: ``os.path.normpath`` folds ``..`` segments purely lexically 

84 (it never follows symlinks). The result tells the caller whether an escape 

85 was caused by the path text itself rather than by a symlink target. 

86 """ 

87 if supplied.is_absolute(): 

88 lexical = Path(os.path.normpath(supplied)) 

89 else: 

90 lexical = Path(os.path.normpath(real_root / supplied)) 

91 return not lexical.is_relative_to(real_root)