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
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +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``.
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 )
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 )
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()
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 )
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)
108 logger.debug(f"Verified directory creation: {p} ({context})")
110 return p