Files
ObsiGate/backend/tools/context.py
T
bruno 5c1823d6d2
CI / lint (push) Successful in 57s
CI / security (push) Successful in 39s
CI / test (push) Failing after 41s
CI / build (push) Skipped
CI / e2e (push) Skipped
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
feat(ai): phase 0 couche d'outils partagee (backend/tools) + tests
- backend/tools/: ToolContext, registry @tool + schemas JSON, audit ai_tool_call
- Services lecture/recherche: list_vaults, list_directory, read_file, search_fulltext, list_tags
- Permissions check_vault_access + resolve_safe_path, confirmation gating (two-step)
- Redaction des secrets, limites de taille, audit JSONL (args sensibles resumes)
- tests/test_tools.py: 30 tests (registry, contexte, execution, confirmation, audit)
- ROADMAP: item #79 phase A livree (A2 partiel)
2026-09-11 12:03:21 -04:00

170 lines
5.4 KiB
Python

"""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