Coverage for src/local_deep_research/security/directory_creation.py: 100%

20 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Security module for verified directory creation operations. 

2 

3This module provides a single audited chokepoint for creating directories. 

4Like ``security/file_write_verifier.py`` it is the sanctioned entry point that 

5all production directory creation must route through (enforced by a 

6pre-commit/CI check that forbids raw ``mkdir``/``makedirs``), giving one place 

7that rejects null bytes and ``..`` traversal, resolves symlinks, and audit-logs 

8every creation. 

9 

10Containment to an expected root is OPT-IN: pass ``root=`` when the target is 

11built from an untrusted / user-influenced component (e.g. a per-user directory 

12named after a username) to require it to resolve inside that root. Most call 

13sites build paths from trusted constants under the data directory and pass no 

14``root`` — for them the value is the audit + the no-bypass guarantee, not 

15containment. 

16""" 

17 

18from pathlib import Path 

19 

20from loguru import logger 

21 

22 

23class DirectoryCreationSecurityError(Exception): 

24 """Raised when a directory creation operation is not allowed by security checks.""" 

25 

26 pass 

27 

28 

29def create_directory( 

30 path: str | Path, 

31 *, 

32 context: str, 

33 root: str | Path | None = None, 

34 parents: bool = True, 

35 exist_ok: bool = True, 

36 mode: int | None = None, 

37) -> Path: 

38 """Create a directory through the audited security chokepoint. 

39 

40 Rejects null bytes and raw ``..`` traversal, resolves the path (following 

41 symlinks), optionally enforces containment within ``root``, creates the 

42 directory, and audit-logs it. 

43 

44 Args: 

45 path: Path to the directory to create. 

46 context: Description of what's being created (for error messages and 

47 audit logs). 

48 root: Optional containment root. When provided, ``path`` must resolve 

49 inside ``root`` (a symlinked parent that escapes it is caught) or a 

50 ``DirectoryCreationSecurityError`` is raised. Pass this only when 

51 ``path`` is built from an untrusted / user-influenced component; 

52 when ``None`` (the default) no containment is enforced. 

53 parents: Create parent directories as needed (default: True). 

54 exist_ok: Do not raise if the directory already exists (default: True). 

55 mode: Optional permission mode to pass to ``Path.mkdir``. If ``None``, 

56 the ``Path.mkdir`` default is used. 

57 

58 Returns: 

59 The resolved ``Path`` of the created directory. 

60 

61 Raises: 

62 DirectoryCreationSecurityError: If the path contains a null byte, a 

63 raw ``..`` traversal segment, or (when ``root`` is given) resolves 

64 outside ``root``. 

65 OSError: If ``path`` or ``root`` cannot be fully resolved (a symlink 

66 loop, a symlink chain whose resolved form outgrows PATH_MAX, an 

67 unreadable component), or if the mkdir itself fails. 

68 

69 Example: 

70 >>> create_directory(data_dir / "cache", context="rag cache") 

71 >>> # opt-in containment for an untrusted leaf name: 

72 >>> create_directory( 

73 ... base / username, context="per-user library", root=base 

74 ... ) 

75 """ 

76 # Reject null bytes in the raw path before any resolution happens. 

77 if "\x00" in str(path): 

78 raise DirectoryCreationSecurityError( 

79 f"Refusing to create directory with null byte in path ({context})" 

80 ) 

81 

82 # Reject a raw ".." segment in the ORIGINAL input path parts. This is 

83 # checked before resolve() because resolve() would silently collapse a 

84 # traversal attempt, hiding the fact that the caller tried to escape. 

85 if ".." in Path(path).parts: 

86 raise DirectoryCreationSecurityError( 

87 f"Refusing to create directory with '..' traversal segment: {path} ({context})" 

88 ) 

89 

90 # Resolution follows symlinks, so a symlinked parent directory that 

91 # escapes the root is caught here. It also works for paths that do not 

92 # yet exist. resolve_allow_missing() tolerates only missing components 

93 # and raises OSError for anything else it cannot resolve (a loop, or a 

94 # symlink chain whose resolved form outgrows PATH_MAX), where a bare 

95 # non-strict resolve() would return a half-resolved path. Lazy import: 

96 # this module is imported very early during bootstrap. 

97 from .path_validator import resolve_allow_missing 

98 

99 p = resolve_allow_missing(Path(path)) 

100 

101 if root is not None: 

102 # Opt-in containment: require the target to resolve inside the given 

103 # root. The resolution above already followed symlinks, so a 

104 # symlinked parent that escapes the root is caught here too. (A path 

105 # is is_relative_to itself, so creating the root itself is allowed.) 

106 root_resolved = resolve_allow_missing(Path(root)) 

107 if not p.is_relative_to(root_resolved): 

108 raise DirectoryCreationSecurityError( 

109 f"Refusing to create directory outside {root_resolved}: {p} ({context})" 

110 ) 

111 

112 if mode is not None: 

113 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Verified directory helper rejects nulls and traversal segments and checks resolved containment when root is supplied 

114 p.mkdir(parents=parents, exist_ok=exist_ok, mode=mode) 

115 else: 

116 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Verified directory helper rejects nulls and traversal segments and checks resolved containment when root is supplied 

117 p.mkdir(parents=parents, exist_ok=exist_ok) 

118 

119 logger.debug(f"Verified directory creation: {p} ({context})") 

120 

121 return p