Source code for klea_utils.mcp.tool_impls.permission

#!/usr/bin/env python3
"""
Path permission checking for file-accessing MCP tools.

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 pathlib import Path
from typing import Any

from klea_utils.mcp.errors import PermissionDeniedError

logger = logging.getLogger(__name__)


# TODO (deferred): replace the hard boundary check with a full permission
# service: config rulesets (allow/deny/ask per path pattern) plus an
# LLM-free interactive approval loop (graph pause + TUI/web input),
# opencode-style.  Tracked as a separate task; for now access is denied to
# any path outside *project_root* and no user approval exists yet.
[docs] def check_path_access( path: str | os.PathLike, project_root: str | os.PathLike | None = None ) -> None: """Raise :class:`PermissionDeniedError` when *path* is not permitted. The permission layer is currently a stub: *path* is allowed only when it resolves inside *project_root* (default: the current working directory). Both sides are fully resolved first, so ``..`` traversal and symlink escapes outside the boundary are caught. Anything else is denied with no way to grant access yet. :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. :raises PermissionDeniedError: when *path* resolves outside the boundary. """ if not str(path).strip(): logger.warning("Permission denied: empty path") raise PermissionDeniedError("Empty path is not allowed") the_path = Path(path).expanduser().resolve() root = ( Path(project_root).expanduser().resolve() if project_root else Path.cwd().resolve() ) try: the_path.relative_to(root) except ValueError: logger.warning(f"Permission denied: {the_path} is outside {root}") raise PermissionDeniedError( f"Access to path outside the project directory is denied: {the_path}" ) from None logger.debug(f"Permission granted: {the_path} is inside {root}")
[docs] def check_tool_arguments_permissions( tool_meta: dict[str, Any] | None, arguments: dict[str, Any], project_root: str | os.PathLike | None = None, ) -> list[str]: """Check the path arguments a tool call would pass against the boundary. Reads the ``checkpaths`` key from *tool_meta* (the ``meta`` dict of an MCP tool, populated by ``register_tools`` from ``ToolInfo.checkpaths``). For each declared argument name that is present in *arguments*, the value is checked with :func:`check_path_access`. Unlike :func:`check_path_access`, this never raises: denied paths are collected and returned as human-readable messages so the caller (the tool caller node) can turn them into a non-halting error result without invoking the tool. Values that are not strings or path-like (e.g. an int) are skipped with a warning, so a mistyped declaration cannot crash the gate. :param tool_meta: Tool ``meta`` dict (``Tool.meta`` from ``mcp_tools``), or ``None``/empty when the tool declares nothing. :param arguments: The arguments dict the caller intends to pass to the tool. :param project_root: Boundary directory for the permission check. Defaults to the current working directory. :returns: List of denial messages; empty when all declared paths are permitted (or no ``checkpaths`` are declared). """ if not tool_meta: return [] checkpaths = tool_meta.get("checkpaths") if not checkpaths: return [] denials: list[str] = [] for arg_name in checkpaths: if arg_name not in arguments: continue value = arguments[arg_name] if not isinstance(value, (str, os.PathLike)): logger.warning( f"Skipping non-path value for declared path arg\n" f"{arg_name = }\n" f"{value = }" ) continue try: check_path_access(value, project_root) except PermissionDeniedError as exc: logger.warning( f"Permission denied for tool arg {arg_name}\n{value = }\n{exc = }" ) denials.append(str(exc)) return denials