"""Tools a playbook can call during chat. Ollama drives the calling: `/api/chat` with a `tools` param returns `message.tool_calls`, and this module is just the registry + dispatch. Most tools READ local state (memory, history, documents, models). A few act: `web_search`/`fetch_url` make outbound HTTP requests, and `remember` WRITES a memory fact. The per-playbook allowlist (`PlaybookItem.tools`) is the security boundary — an action tool only fires when a playbook explicitly lists it. """ from __future__ import annotations import json from typing import Awaitable, Callable from .memory.store import store, MemoryItem from .ollama_manager import get_ollama_manager async def _search_memory(query: str = "", **_) -> str: q = (query or "").strip().lower() hits = [ {"section": it.section, "text": it.text} for it in store.all() if not q or q in it.text.lower() or q in (it.section or "").lower() or any(q in t.lower() for t in it.tags) ] return json.dumps(hits[:20]) async def _search_history(query: str = "", **_) -> str: # Hybrid recall: semantic (embeddings) unioned with lexical, falls back to # lexical if embeddings are down. Same retrieval the chat endpoint uses. convs = await store.semantic_search_conversations( query or "", get_ollama_manager().embed, limit=3 ) return json.dumps([{"matches": c.get("matches", [])} for c in convs]) async def _list_models(**_) -> str: return json.dumps(await get_ollama_manager().list_models()) async def _search_documents(query: str = "", **_) -> str: hits = await store.search_documents(query or "", get_ollama_manager().embed, limit=3) return json.dumps([{"title": h["title"], "text": h["text"]} for h in hits]) async def _get_time(**_) -> str: from datetime import datetime return json.dumps({"now": datetime.now().isoformat(timespec="seconds")}) async def _web_search(query: str = "", **_) -> str: import asyncio as _a from .search import web_search res = await _a.to_thread(web_search, query or "", 4) return res or "(no results)" _FETCH_MAX_REDIRECTS = 5 def _ip_is_blocked(ip: str) -> bool: """True if an address is one an outbound fetch has no business reaching: loopback, RFC1918/ULA private, link-local (incl. 169.254.169.254 cloud metadata), multicast, reserved, or unspecified. IPv4-mapped IPv6 is unwrapped first so ::ffff:127.0.0.1 can't sneak a loopback past the check.""" import ipaddress try: addr = ipaddress.ip_address(ip.split("%")[0]) # drop any IPv6 zone id except ValueError: return True # unparseable -> refuse rather than guess mapped = getattr(addr, "ipv4_mapped", None) if mapped is not None: addr = mapped return ( addr.is_loopback or addr.is_private or addr.is_link_local or addr.is_multicast or addr.is_reserved or addr.is_unspecified ) def _ssrf_guard(host: str) -> str | None: """Resolve a hostname and return an error string if ANY of its A/AAAA records is a blocked address, else None. Checking every answer stops a name from smuggling one private record alongside a public one. ponytail: this validates then httpx re-resolves on connect, so a sub-second DNS-rebind could still slip a private address through the TOCTOU gap. That's an advanced attack against a playbook-gated, single-user tool; pin the connection to the resolved IP if this ever faces untrusted callers.""" import socket if not host: return "missing host" try: infos = socket.getaddrinfo(host, None) except socket.gaierror as e: return f"cannot resolve host: {e}" ips = {info[4][0] for info in infos} if not ips: return "host did not resolve" blocked = [ip for ip in ips if _ip_is_blocked(ip)] if blocked: return f"refusing to fetch a private/loopback/link-local address ({', '.join(sorted(blocked))})" return None async def _fetch_url(url: str = "", **_) -> str: import re import httpx from urllib.parse import urlparse, urljoin url = (url or "").strip() if not url.startswith(("http://", "https://")): return json.dumps({"error": "url must start with http:// or https://"}) # SSRF guard: validate the host of the initial URL AND every redirect hop # against the private/loopback/link-local block-list before connecting, so a # granted fetch_url can't be steered at 127.0.0.1:11434, cloud metadata, or # LAN hosts — and a public URL can't 302 its way there either. try: async with httpx.AsyncClient(timeout=15.0, follow_redirects=False) as c: for _ in range(_FETCH_MAX_REDIRECTS + 1): parsed = urlparse(url) if parsed.scheme not in ("http", "https"): return json.dumps({"error": "only http(s) URLs are allowed"}) err = _ssrf_guard(parsed.hostname or "") if err: return json.dumps({"error": f"blocked: {err}"}) r = await c.get(url, headers={"User-Agent": "NexusOS/1.0"}) location = r.headers.get("location") if r.is_redirect and location: url = urljoin(url, location) continue r.raise_for_status() html = r.text break else: return json.dumps({"error": "too many redirects"}) except Exception as e: return json.dumps({"error": f"fetch failed: {e}"}) text = re.sub(r"(?is)<(script|style).*?", " ", html) text = re.sub(r"(?s)<[^>]+>", " ", text) text = re.sub(r"\s+", " ", text).strip() return text[:4000] async def _remember(text: str = "", section: str = "General", **_) -> str: """WRITE tool: persist a memory fact. First action tool — allowlist-gated.""" import uuid as _uuid text = (text or "").strip() if not text: return json.dumps({"error": "text is required"}) store.add(MemoryItem(id=str(_uuid.uuid4()), section=(section or "General"), text=text)) return json.dumps({"saved": text, "section": section or "General"}) # name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format. REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = { "search_memory": ( { "type": "function", "function": { "name": "search_memory", "description": "Search the user's persistent memory facts. Empty query returns all facts.", "parameters": { "type": "object", "properties": {"query": {"type": "string", "description": "text to match"}}, }, }, }, _search_memory, ), "search_history": ( { "type": "function", "function": { "name": "search_history", "description": "Search past conversations for exchanges containing the query text.", "parameters": { "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], }, }, }, _search_history, ), "list_models": ( { "type": "function", "function": { "name": "list_models", "description": "List the locally installed Ollama models.", "parameters": {"type": "object", "properties": {}}, }, }, _list_models, ), "search_documents": ( { "type": "function", "function": { "name": "search_documents", "description": "Search the user's uploaded documents for relevant passages.", "parameters": { "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], }, }, }, _search_documents, ), "get_time": ( { "type": "function", "function": { "name": "get_time", "description": "Get the current local date and time.", "parameters": {"type": "object", "properties": {}}, }, }, _get_time, ), "web_search": ( { "type": "function", "function": { "name": "web_search", "description": "Search the web (DuckDuckGo) and return the top result snippets.", "parameters": { "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], }, }, }, _web_search, ), "fetch_url": ( { "type": "function", "function": { "name": "fetch_url", "description": "Fetch a web page and return its visible text (truncated).", "parameters": { "type": "object", "properties": {"url": {"type": "string", "description": "http(s) URL"}}, "required": ["url"], }, }, }, _fetch_url, ), "remember": ( { "type": "function", "function": { "name": "remember", "description": "Save a durable fact to the user's persistent memory.", "parameters": { "type": "object", "properties": { "text": {"type": "string", "description": "the fact to remember"}, "section": {"type": "string", "description": "optional category, e.g. Health"}, }, "required": ["text"], }, }, }, _remember, ), } # Tools that act (write local state or reach the network). These require an # explicit consent gate (settings.allow_action_tools) on top of the per-playbook # allowlist — a playbook granting one isn't enough on its own. ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember"}) def is_action(name: str) -> bool: return name in ACTION_TOOLS def schemas_for(names: list[str], allow_actions: bool = True) -> list[dict]: """Tool schemas for a playbook's allowlist; unknown names are dropped. When allow_actions is False, action tools are withheld so the model can't even call them.""" return [ REGISTRY[n][0] for n in (names or []) if n in REGISTRY and (allow_actions or not is_action(n)) ] async def dispatch(name: str, args: dict | None) -> str: """Run a tool by name. Never raises — returns an error string on failure.""" entry = REGISTRY.get(name) if not entry: return json.dumps({"error": f"unknown tool: {name}"}) try: return await entry[1](**(args or {})) except Exception as e: # a broken tool must not kill the chat loop return json.dumps({"error": f"{name} failed: {e}"})