Source code for klea_utils.mcp.path_approval
#!/usr/bin/env python3
"""
Session-scoped path-approval helpers.
Pure helpers for the ``allowed_dirs`` / ``allowed_files`` state used by the
path gate (ADR-0007) and the ``/paths`` command (ADR-0047): resolve and
validate directory approvals, normalise the edited lists, find the entry that
covers a path, and format the user-facing listing. Command-specific parsing
and messages stay in the app command handler.
See ``devdocs/system/mcp-permissions.md`` and ``system/session-commands.md``.
File: klea_utils/mcp/path_approval.py
Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""
import logging
import os
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
[docs]
def resolve_project_root(
project_root: str | os.PathLike | None = None,
) -> Path:
"""Return the resolved session project root.
Falls back to the current directory when *project_root* is unset or cannot
be resolved.
:param project_root: The session root, or ``None``.
:returns: A resolved directory path.
"""
try:
return Path(project_root or Path.cwd()).expanduser().resolve()
except (OSError, RuntimeError):
return Path.cwd().resolve()
[docs]
def resolve_user_path(value: str | os.PathLike, base: str | os.PathLike) -> Path:
"""Resolve *value* relative to *base* without requiring it to exist.
:param value: The user-supplied path.
:param base: The directory relative paths resolve against.
:returns: A resolved path.
"""
candidate = Path(value).expanduser()
if not candidate.is_absolute():
candidate = Path(base).expanduser() / candidate
return candidate.resolve(strict=False)
[docs]
def is_relative_to(path: Path, parent: Path) -> bool:
"""Return whether *path* is inside *parent*."""
try:
path.relative_to(parent)
except ValueError:
return False
return True
[docs]
def normalise_directory(
project_root: str | os.PathLike | None, value: str | os.PathLike
) -> tuple[str | None, str]:
"""Validate one directory approval.
The directory must exist, must not be inside the project root (which is
always permitted), and is returned resolved.
:param project_root: The session project root.
:param value: The user-supplied directory.
:returns: ``(resolved, "")`` on success, or ``(None, error)``.
"""
try:
resolved = Path(value).expanduser()
if not resolved.is_absolute():
resolved = resolve_project_root(project_root) / resolved
resolved = resolved.resolve(strict=True)
except (OSError, RuntimeError) as exc:
return None, f"Cannot resolve {value!r}: {exc}"
if not resolved.is_dir():
return None, f"Not a directory: {resolved}"
root = resolve_project_root(project_root)
if resolved == root or is_relative_to(resolved, root):
return None, f"{resolved} is already inside the project root."
return str(resolved), ""
[docs]
def normalise_allowed_dirs(
project_root: str | os.PathLike | None, values: list[Any]
) -> tuple[list[str], list[str]]:
"""Validate edited directory values.
:param project_root: The session project root.
:param values: The submitted directory values.
:returns: ``(accepted, ignored)``; *ignored* holds the validation errors.
"""
accepted: list[str] = []
ignored: list[str] = []
for value in values:
raw = str(value).strip()
if not raw:
continue
resolved, error = normalise_directory(project_root, raw)
if resolved is None:
ignored.append(error)
continue
if resolved not in accepted:
accepted.append(resolved)
return accepted, ignored
[docs]
def normalise_allowed_files(current: list[str], values: list[Any]) -> list[str]:
"""Keep only existing sensitive-file approvals that remain selected.
The editor is additive-disabled for sensitive files: new values are
ignored, removals are honoured.
:param current: The current ``allowed_files`` entries.
:param values: The submitted values.
:returns: The remaining entries, in their original order.
"""
selected = {str(value).strip() for value in values if str(value).strip()}
return [path for path in current if path in selected]
[docs]
def entry_matches_path(entry: str, resolved: Path) -> bool:
"""Return whether an approval *entry* resolves to *resolved*."""
try:
return str(Path(entry).expanduser().resolve()) == str(resolved)
except (OSError, RuntimeError):
return entry == str(resolved)
[docs]
def find_matching_entry(entries: list[str], resolved: Path) -> str | None:
"""Return the exact approval *entry* matching *resolved*, if any."""
for entry in entries:
if entry_matches_path(entry, resolved):
return entry
return None
[docs]
def find_covering_dir(allowed_dirs: list[str], resolved: Path) -> str | None:
"""Return the allowed directory that covers *resolved*, if any."""
for entry in allowed_dirs:
try:
parent = Path(entry).expanduser().resolve()
except (OSError, RuntimeError):
continue
if resolved == parent or is_relative_to(resolved, parent):
return entry
return None