"""Execution context and shared primitives for the AI tool layer. The tool layer is deliberately transport-agnostic: the same tool functions are invoked by the in-app assistant (function calling) and by the MCP server. This module holds the context object that carries the caller identity, the permission helpers, and the domain error types shared across the layer. """ from __future__ import annotations import logging from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import Any from backend.auth.middleware import check_vault_access logger = logging.getLogger("obsigate.tools") class ToolMode(str, Enum): """How a tool was invoked.""" IN_APP = "in_app" MCP = "mcp" class ToolScope(str, Enum): """Which front a tool is exposed to.""" IN_APP = "in_app" MCP = "mcp" class ToolRisk(str, Enum): """Risk level of a tool. ``READ`` tools run without confirmation. ``WRITE`` and ``DANGEROUS`` tools require an explicit confirmation (two-step ``propose``/``apply`` for MCP, an "Apply" card in the in-app UI). """ READ = "read" WRITE = "write" DANGEROUS = "dangerous" class ToolError(Exception): """Base class for tool execution errors. Carries a stable, machine-readable ``code`` so callers (agent loop, MCP server) can react without parsing the message. """ code = "tool_error" def __init__(self, message: str, *, code: str | None = None, details: dict[str, Any] | None = None): super().__init__(message) self.message = message if code: self.code = code self.details = details or {} def to_dict(self) -> dict[str, Any]: return {"ok": False, "error": {"code": self.code, "message": self.message, "details": self.details}} class ToolNotFoundError(ToolError): code = "not_found" class ToolPermissionError(ToolError): code = "permission_denied" class ToolValidationError(ToolError): code = "invalid_arguments" class ToolConfirmationRequired(ToolError): """Raised when a mutating tool is invoked without confirmation. This is the hook for the two-step ``propose``/``apply`` mechanism: the ``propose`` phase surfaces this payload to the user, and the ``apply`` phase re-invokes the tool with ``confirm=True``. """ code = "confirmation_required" def __init__(self, tool: str, arguments: dict[str, Any], message: str | None = None): super().__init__(message or f"Confirmation required for '{tool}'", code="confirmation_required") self.tool = tool self.arguments = arguments def to_dict(self) -> dict[str, Any]: payload = super().to_dict() payload["error"]["tool"] = self.tool payload["error"]["arguments"] = self.arguments return payload @dataclass class ToolContext: """Carries the caller identity and execution options for a tool call. Attributes: user: Authenticated user dict (as returned by ``get_current_user``). mode: Whether the call originates from the in-app assistant or MCP. confirmed: True once a mutating action has been approved. ip: Optional client IP for auditing. audit_enabled: Set to False to skip audit logging (tests, dry runs). metadata: Free-form caller metadata (conversation id, client name…). """ user: dict[str, Any] mode: ToolMode = ToolMode.IN_APP confirmed: bool = False ip: str | None = None audit_enabled: bool = True metadata: dict[str, Any] = field(default_factory=dict) @property def username(self) -> str: return self.user.get("username", "unknown") def has_vault_access(self, vault: str) -> bool: """Return True if the caller may access *vault*.""" return bool(vault) and check_vault_access(vault, self.user) def require_vault_access(self, vault: str) -> None: """Raise :class:`ToolPermissionError` unless the caller can access *vault*.""" if not self.has_vault_access(vault): raise ToolPermissionError( f"Access denied to vault '{vault}'", code="vault_access_denied", details={"vault": vault}, ) def resolve_safe_path(vault_root: Path, relative_path: str) -> Path: """Resolve a vault-relative path, rejecting traversal outside the vault. Mirrors ``backend.main._resolve_safe_path`` but raises a domain error instead of ``HTTPException`` so the tool layer stays transport-agnostic. Raises: ToolPermissionError: When the resolved path escapes the vault root. ToolError: When the path cannot be resolved. """ full_path = vault_root / (relative_path or "") try: resolved = full_path.resolve(strict=False) root = vault_root.resolve(strict=False) except Exception as e: raise ToolError(f"Path resolution error: {e}", code="path_error") from e try: resolved.relative_to(root) except ValueError: # Case-insensitive fallback for Windows / Docker path casing. if not str(resolved).lower().startswith(str(root).lower()): logger.warning(f"Path outside vault: vault={root}, requested={relative_path}") raise ToolPermissionError( "Access denied: path outside vault", code="path_outside_vault", details={"path": relative_path}, ) from None return resolved