Source code for klea_utils.mcp.tool_impls.permission
#!/usr/bin/env python3
"""
Path boundary helpers for the client-side permission gate.
The tool implementations no longer call these; the client-side gate uses
:func:`resolve_path` / :func:`path_is_allowed` / :func:`permitted_roots`
(:mod:`klea_utils.mcp.path_detect`) and :func:`check_path_access` remains a
convenience that raises on denial. See ``devdocs/system/mcp-permissions.md``
and ``devdocs/adr/0007-mcp-permissions.md``.
Containment is path-based and taken as a snapshot. Two known limitations:
a *hard link* inside the root to an inode whose other names are outside it is
not detectable path-wise and is allowed; and a path swapped for an external
symlink after the check (TOCTOU) could still be opened. Hardening would need
a per-open check (``openat``/``O_NOFOLLOW``).
File: klea_utils/mcp/tool_impls/permission.py
Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""
import logging
import os
from collections.abc import Sequence
from pathlib import Path
from klea_utils.mcp.errors import PermissionDeniedError
logger = logging.getLogger(__name__)
def _is_relative_to(path: Path, root: Path) -> bool:
"""Return whether resolved *path* is inside resolved *root*."""
try:
path.relative_to(root)
except ValueError:
return False
return True
[docs]
def permitted_roots(
project_root: str | os.PathLike | None = None,
allowed_dirs: Sequence[str] | None = None,
) -> list[Path]:
"""Return the resolved roots a path is allowed to be inside.
All are fully resolved, so ``..`` traversal and symlink escapes are
caught against every root. Blank or unresolvable ``allowed_dirs`` entries
are skipped (fail open for the root, not the path).
:param project_root: Primary boundary; defaults to the current directory.
:param allowed_dirs: Additional roots, for example directories a user
approved for the session (ADR-0007 update 2026-10-08).
:returns: A list of resolved boundary roots.
"""
try:
primary = (
Path(project_root).expanduser().resolve()
if project_root
else Path.cwd().resolve()
)
except (OSError, RuntimeError, ValueError) as exc:
logger.warning(
f"Could not resolve project root {project_root!r}: {exc}; "
"using the current directory"
)
primary = Path.cwd().resolve()
roots = [primary]
for entry in allowed_dirs or []:
if not str(entry).strip():
continue
try:
roots.append(Path(entry).expanduser().resolve())
except (OSError, RuntimeError, ValueError) as exc:
logger.warning(f"Skipping unresolvable allowed directory {entry!r}: {exc}")
return roots
[docs]
def resolve_path(path: str | os.PathLike) -> Path | None:
"""Resolve *path* to an absolute, symlink-free path, or ``None``.
Returns ``None`` for an empty path or one that cannot be resolved (for
example a symlink loop), so callers can fail closed. A missing target is
resolved non-strictly so the boundary check still applies to its location.
:param path: Path to resolve.
:returns: The resolved path, or ``None``.
"""
if not str(path).strip():
return None
try:
base_path = Path(path).expanduser()
except (OSError, RuntimeError, ValueError) as exc:
logger.warning(f"Could not expand path {path!r}: {exc}")
return None
try:
# ``strict=True`` makes a symlink loop (ELOOP) or an unreadable parent
# raise here instead of being silently returned as-is by the non-strict
# resolve.
return base_path.resolve(strict=True)
except FileNotFoundError:
# A missing target is allowed through so the tool can report nearby
# entries; the boundary check still applies to its location.
try:
return base_path.resolve(strict=False)
except (OSError, RuntimeError):
return None
except (OSError, RuntimeError):
return None
[docs]
def path_is_allowed(path: Path, roots: list[Path]) -> bool:
"""Return whether resolved *path* is inside any of *roots*.
:param path: An already-resolved path.
:param roots: Resolved boundary roots (see :func:`permitted_roots`).
:returns: True when *path* is inside at least one root.
"""
return any(_is_relative_to(path, root) for root in roots)
[docs]
def check_path_access(
path: str | os.PathLike,
project_root: str | os.PathLike | None = None,
*,
allowed_dirs: Sequence[str] | None = None,
) -> None:
"""Raise :class:`PermissionDeniedError` when *path* is not permitted.
*path* is allowed when it resolves inside *project_root* (default: the
current working directory) or inside any of *allowed_dirs*. Both sides
are fully resolved first, so ``..`` traversal and symlink escapes outside
the boundary are caught.
This is the utility used by the client-side tool-call gate
(:mod:`klea_utils.mcp.path_detect`); it is no longer called inside tool
implementations (ADR-0007 update 2026-10-08).
:param path: File or directory path the tool wants to access.
:param project_root: Boundary directory inside which access is allowed.
Defaults to the current working directory.
:param allowed_dirs: Additional directories permitted for this check
(session approvals), or ``None``.
:raises PermissionDeniedError: when *path* resolves outside every root, or
cannot be resolved at all (for example a symlink loop).
"""
if not str(path).strip():
logger.warning("Permission denied: empty path")
raise PermissionDeniedError("Empty path is not allowed")
the_path = resolve_path(path)
if the_path is None:
logger.warning(f"Permission denied: cannot resolve {path!r}")
raise PermissionDeniedError(f"Cannot resolve path: {path}")
roots = permitted_roots(project_root, allowed_dirs)
if not path_is_allowed(the_path, roots):
logger.warning(f"Permission denied: {the_path} is outside {roots}")
raise PermissionDeniedError(
f"Access to path outside the project directory is denied: {the_path}"
)
logger.debug(f"Permission granted: {the_path} is inside {roots}")