"""Tool-layer secrets — user-configured tokens & API keys (#103). The connected-source (Gitea / GitHub) and keyed web-search (Tavily, Brave, SerpAPI, Exa) tools read their credentials through this module instead of ``os.environ`` directly. The value comes from the store the user edits in the configuration page (``data/api_keys.json`` — the same file the AI provider keys use) first, then falls back to the environment (Infisical-injected in production). Nothing is ever hard-coded and no tool result carries a secret (the registry redacts payloads). Allowed names are whitelisted: only the variables below can be stored or deleted from the configuration page. """ from __future__ import annotations import json import logging import os import threading from pathlib import Path logger = logging.getLogger("obsigate.tools.secrets") # Whitelisted configuration names (config page « Sources connectées & recherche »). TOOL_KEY_NAMES: tuple[str, ...] = ( "OBSIGATE_TAVILY_API_KEY", "OBSIGATE_BRAVE_API_KEY", "OBSIGATE_SERPAPI_API_KEY", "OBSIGATE_EXA_API_KEY", "OBSIGATE_GITEA_URL", "OBSIGATE_GITEA_TOKEN", "OBSIGATE_GITHUB_TOKEN", ) _SECRET_MARKERS = ("API_KEY", "TOKEN") # ROADMAP #85 T10a — verrou autour des read-modify-write du store de clés. _lock = threading.RLock() def _keys_file() -> Path: base = os.environ.get("OBSIGATE_DATA_DIR", "data") return Path(base) / "api_keys.json" def _read_keys() -> dict: path = _keys_file() if not path.exists(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError) as e: logger.warning("tool key store unreadable (%s): %s", path, e) return {} return data if isinstance(data, dict) else {} def _write_keys(data: dict) -> None: path = _keys_file() path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps(data, indent=2), encoding="utf-8") tmp.replace(path) def is_secret_name(name: str) -> bool: """True for API keys / tokens (masked in API responses); URLs are clear.""" return any(marker in name for marker in _SECRET_MARKERS) def mask_value(name: str, value: str) -> str: """Mask a secret for display; non-secret values (URLs) are returned as-is.""" if not value: return "" if not is_secret_name(name): return value return value[:4] + "..." + value[-4:] if len(value) > 8 else "***" def get_tool_key(name: str) -> str: """Stored (configuration page) value first, then environment fallback.""" if name not in TOOL_KEY_NAMES: return os.environ.get(name, "").strip() stored = _read_keys().get(name) if isinstance(stored, str) and stored.strip(): return stored.strip() return os.environ.get(name, "").strip() def set_tool_key(name: str, value: str) -> None: """Persist one whitelisted key into the store (admin configuration page).""" if name not in TOOL_KEY_NAMES: raise ValueError(f"Clé non prise en charge: {name}") value = (value or "").strip() with _lock: keys = _read_keys() if value: keys[name] = value else: keys.pop(name, None) _write_keys(keys) def delete_tool_key(name: str) -> bool: """Remove one key from the store; return True when it existed.""" if name not in TOOL_KEY_NAMES: raise ValueError(f"Clé non prise en charge: {name}") with _lock: keys = _read_keys() if name in keys: del keys[name] _write_keys(keys) return True return False