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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Security module for verified directory creation operations.
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.
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"""
18from pathlib import Path
20from loguru import logger
23class DirectoryCreationSecurityError(Exception):
24 """Raised when a directory creation operation is not allowed by security checks."""
26 pass
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.
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.
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.
58 Returns:
59 The resolved ``Path`` of the created directory.
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.
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 )
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 )
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
99 p = resolve_allow_missing(Path(path))
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 )
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)
119 logger.debug(f"Verified directory creation: {p} ({context})")
121 return p