"""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"}) # --- Repo file access (read-only, scoped to PROJECT_ROOT) ------------------- # Paths never leave the repo: every request is resolve()d and checked against # PROJECT_ROOT, which also kills symlink escapes. _DENIED covers the parts of # the tree that are either secrets, private data, or multi-GB noise. _DENIED = { ".git", ".env", "Promethean", "node_modules", "models", "ollama", "runtime", "dist", "__pycache__", ".git-credentials", } _READ_MAX = 60_000 def _repo_path(rel: str) -> "tuple[object, str | None]": """Resolve a repo-relative path. Returns (path, error-string).""" from .nexus_config import PROJECT_ROOT rel = (rel or "").strip().lstrip("/") if not rel: return None, "path is required" target = (PROJECT_ROOT / rel).resolve() if not target.is_relative_to(PROJECT_ROOT): return None, "path escapes the project root" parts = set(target.relative_to(PROJECT_ROOT).parts) if parts & _DENIED or target.name.endswith((".db", ".db.sql", ".pem", ".key")): return None, f"{rel} is not readable" return target, None async def _read_file(path: str = "", **_) -> str: target, err = _repo_path(path) if err: return json.dumps({"error": err}) if not target.is_file(): return json.dumps({"error": f"{path} does not exist"}) try: text = target.read_text(encoding="utf-8", errors="replace") except OSError as e: return json.dumps({"error": f"cannot read {path}: {e}"}) return json.dumps({ "path": path, "truncated": len(text) > _READ_MAX, "content": text[:_READ_MAX], }) async def _list_files(pattern: str = "", **_) -> str: """Glob the repo so the model discovers real paths instead of inventing them.""" from .nexus_config import PROJECT_ROOT pattern = (pattern or "**/*.py").strip().lstrip("/") hits = [] for f in PROJECT_ROOT.glob(pattern): if not f.is_file(): continue target, err = _repo_path(str(f.relative_to(PROJECT_ROOT))) if err: continue hits.append(str(f.relative_to(PROJECT_ROOT))) if len(hits) >= 200: break return json.dumps(sorted(hits)) # The one place that says which languages the render window supports. The tool # schema's `lang` enum and the capability line in the system prompt are derived # from these keys rather than repeated. # # The frontend keeps its own matching registry (PREVIEW_LANGS in # interface/web/src/preview/languages.js) because the two sides need different # things per language - this side describes them, that side renders them - and # neither should depend on the other at runtime. tests/test_tools.py asserts the key sets # stay equal, so drift fails the check gate instead of silently degrading to a # plain code block in the chat. PREVIEW_LANGS: dict[str, dict] = { "html": {"summary": "self-contained HTML document"}, "svg": {"summary": "standalone SVG image"}, "jsx": {"summary": "single Preact/React component (JSX)"}, "tsx": {"summary": "single Preact/React component (TypeScript JSX)"}, } def _lang_prose() -> str: """'html or svg' — the supported languages as a phrase for prompts/errors.""" names = list(PREVIEW_LANGS) if len(names) < 2: return names[0] if names else "" return f"{', '.join(names[:-1])} or {names[-1]}" async def _render_preview( lang: str = "html", title: str = "", markup: str = "", purpose: str = "", **_, ) -> str: """Package a live-preview fence. Read-only: nothing is executed server-side; the chat UI parses and renders the fence in a sandboxed iframe.""" lang = (lang or "html").strip().lower() markup = (markup or "").strip() title = (title or "").strip() purpose = (purpose or "").strip() if lang not in PREVIEW_LANGS: return json.dumps({"ok": False, "error": f"lang must be {_lang_prose()}"}) if not markup: return json.dumps({ "ok": False, "error": f"markup is required — send the complete {lang} preview.", }) fence = f"```{lang}\n{markup}\n```" return json.dumps({ "ok": True, "title": title or None, "purpose": purpose or None, "instruction": ( "Write a short intro, then paste this fenced block exactly as it is. " "Do not wrap it in a second fence, resize it, or rewrite the code." ), "fence": fence, }) # 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, ), "read_file": ( { "type": "function", "function": { "name": "read_file", "description": "Read a source file from the NexusOS repository. Path is relative to the project root, e.g. 'synapse/main.py'.", "parameters": { "type": "object", "properties": {"path": {"type": "string", "description": "repo-relative file path"}}, "required": ["path"], }, }, }, _read_file, ), "list_files": ( { "type": "function", "function": { "name": "list_files", "description": "List files in the NexusOS repository matching a glob, e.g. 'synapse/**/*.py' or 'interface/web/src/*.jsx'. Use this to find real paths before reading.", "parameters": { "type": "object", "properties": {"pattern": {"type": "string", "description": "glob relative to the project root"}}, }, }, }, _list_files, ), "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, ), "render_preview": ( { "type": "function", "function": { "name": "render_preview", # Written as instructions TO you, imperative and short. Earlier # versions narrated what "the user" wants and listed numbered # requirements; weak models echoed that narration back as their # reply — asking the user to clarify an already-clear request, # in the third person, instead of building anything. Keep this # terse, keep it second-person, and add nothing the model can # recite in place of acting. "description": ( f"Package a working visual or interactive demo as self-contained " f"{_lang_prose()}. Inline required CSS and JS; the sandbox has no " "network, so external resources will not load. Paste the returned " "`fence` into your reply unchanged." ), "parameters": { "type": "object", "properties": { "lang": { "type": "string", "enum": list(PREVIEW_LANGS), "description": ( "Preview language tag for the fenced block: " + "; ".join( f"{name} ({spec['summary']})" for name, spec in PREVIEW_LANGS.items() ) ), }, "title": { "type": "string", "description": "Short label for the visual.", }, "purpose": { "type": "string", "description": "One sentence: what this visual shows.", }, "markup": { "type": "string", "description": ( "Complete self-contained source for the selected preview " "language. React, ReactDOM, Preact, and Preact hooks are " "available locally; other packages and external resources " "cannot be loaded." ), }, }, "required": ["lang", "markup"], }, }, }, _render_preview, ), "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"}) # Always advertised when the user asks for a visual (see wants_render_preview). # Not playbook-gated — the render window is a standing UI capability. STANDING_TOOLS = frozenset({"render_preview"}) # User-message cues that justify running the (slow, non-stream) tool loop with # render_preview. Kept narrow so ordinary chat isn't blocked behind a tool turn. _RENDER_HINTS = ( "visual", "visuals", "visualize", "visualization", "chart", "charts", "graph", "graphs", "diagram", "diagrams", "canvas", "plot", "plots", "interactive", "animation", "animations", "render_preview", "render preview", "svg", "draw me", "live preview", "demonstrate", "demo", "html demo", "html snippet", "html file", # Ways of asking for something that reacts to the pointer. "interactive" # alone missed "mouse-over sensitive", and with it the whole feature. "hover", "mouse", "drag", "click on", "real-time", "realtime", "simulation", "simulations", "simulate", "particle", "particles", "animate", # Every language the render window can display. Naming one is asking for a # preview, and this way a language added to PREVIEW_LANGS starts hinting # for itself instead of being unreachable until someone edits this tuple - # which is exactly what happened to jsx/tsx. ) + tuple(PREVIEW_LANGS) def wants_render_preview(message: str) -> bool: """True when this turn should advertise render_preview / enter the tool loop.""" import re lower = (message or "").lower() return any( re.search(rf"(? 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)) ] def standing_schemas() -> list[dict]: """Schemas that ship with visual turns (currently just render_preview).""" return schemas_for(sorted(STANDING_TOOLS), allow_actions=True) 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}"})