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

19 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +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 

66 Example: 

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

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

69 >>> create_directory( 

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

71 ... ) 

72 """ 

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

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

75 raise DirectoryCreationSecurityError( 

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

77 ) 

78 

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

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

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

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

83 raise DirectoryCreationSecurityError( 

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

85 ) 

86 

87 # resolve() follows symlinks, so a symlinked parent directory that 

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

89 # yet exist. 

90 p = Path(path).resolve() 

91 

92 if root is not None: 

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

94 # root. resolve() above already followed symlinks, so a symlinked 

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

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

97 root_resolved = Path(root).resolve() 

98 if not p.is_relative_to(root_resolved): 

99 raise DirectoryCreationSecurityError( 

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

101 ) 

102 

103 if mode is not None: 

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

105 else: 

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

107 

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

109 

110 return p