Runs diff -u on two project-scoped files. Low risk, no admin required. Covers code review, config comparison, and before/after verification. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
521 lines
21 KiB
Python
521 lines
21 KiB
Python
"""
|
|
Orchestrator tool registry.
|
|
|
|
Declarations live in each domain module alongside their callables.
|
|
This file assembles them into the unified registry used by both engines.
|
|
|
|
To add a new tool:
|
|
1. Implement it in tools/<domain>.py — add the async callable + append to DECLARATIONS
|
|
2. Import the callable here and add it to _CALLABLES
|
|
3. If admin-only, add it to TOOL_ROLES; if confirmation needed, add to CONFIRM_REQUIRED
|
|
|
|
IMPORTANT: These tools are separate from the ae_* MCP tools used by the fleet agents.
|
|
Do not modify the ae_* MCP server to support orchestrator needs.
|
|
"""
|
|
|
|
from google.genai import types
|
|
|
|
# ── Callable imports ──────────────────────────────────────────────────────────
|
|
|
|
from tools.web import search as _web_search, http_fetch as _http_fetch, web_read as _web_read, http_post as _http_post
|
|
from tools.ae_knowledge import (
|
|
journal_list as _ae_journal_list,
|
|
journal_search as _ae_journal_search,
|
|
journal_entry_read as _ae_journal_entry_read,
|
|
journal_entries_list as _ae_journal_entries_list,
|
|
journal_entry_create as _ae_journal_entry_create,
|
|
journal_entry_update as _ae_journal_entry_update,
|
|
journal_entry_disable as _ae_journal_entry_disable,
|
|
journal_entry_append as _ae_journal_entry_append,
|
|
journal_entry_prepend as _ae_journal_entry_prepend,
|
|
)
|
|
from tools.ae_tasks import task_list as _ae_task_list
|
|
from tools.files import (
|
|
project_file_read as _project_file_read,
|
|
project_file_list as _project_file_list,
|
|
file_stat as _file_stat,
|
|
file_grep as _file_grep,
|
|
file_diff as _file_diff,
|
|
file_syntax_check as _file_syntax_check,
|
|
file_read as _file_read,
|
|
file_list as _file_list,
|
|
file_write as _file_write,
|
|
session_read as _session_read,
|
|
session_search as _session_search,
|
|
)
|
|
from tools.system import (
|
|
shell_exec as _shell_exec,
|
|
claude_allow_dir as _claude_allow_dir,
|
|
cortex_restart as _cortex_restart,
|
|
cortex_logs as _cortex_logs,
|
|
cortex_status as _cortex_status,
|
|
cortex_update as _cortex_update,
|
|
)
|
|
from tools.tasks import (
|
|
task_list as _task_list,
|
|
task_create as _task_create,
|
|
task_update as _task_update,
|
|
task_complete as _task_complete,
|
|
)
|
|
from tools.cron import (
|
|
cron_list as _cron_list,
|
|
cron_add as _cron_add,
|
|
cron_remove as _cron_remove,
|
|
cron_toggle as _cron_toggle,
|
|
)
|
|
from tools.reminders import (
|
|
reminders_add as _reminders_add,
|
|
reminders_list as _reminders_list,
|
|
reminders_remove as _reminders_remove,
|
|
reminders_clear as _reminders_clear,
|
|
)
|
|
from tools.scratch import (
|
|
scratch_read as _scratch_read,
|
|
scratch_write as _scratch_write,
|
|
scratch_append as _scratch_append,
|
|
scratch_clear as _scratch_clear,
|
|
)
|
|
from tools.notify import nc_talk_send as _nc_talk_send, email_send as _email_send, web_push as _web_push, nc_talk_history as _nc_talk_history
|
|
from tools.agent_notes import (
|
|
agent_notes_read as _agent_notes_read,
|
|
agent_notes_write as _agent_notes_write,
|
|
agent_notes_append as _agent_notes_append,
|
|
agent_notes_clear as _agent_notes_clear,
|
|
)
|
|
from tools.agents import spawn_agent as _spawn_agent
|
|
from tools.homeassistant import (
|
|
ha_get_state as _ha_get_state,
|
|
ha_get_states as _ha_get_states,
|
|
ha_call_service as _ha_call_service,
|
|
)
|
|
|
|
# ── Declaration imports ───────────────────────────────────────────────────────
|
|
|
|
import tools.web as _mod_web
|
|
import tools.ae_knowledge as _mod_ae_knowledge
|
|
import tools.ae_tasks as _mod_ae_tasks
|
|
import tools.files as _mod_files
|
|
import tools.system as _mod_system
|
|
import tools.tasks as _mod_tasks
|
|
import tools.cron as _mod_cron
|
|
import tools.reminders as _mod_reminders
|
|
import tools.scratch as _mod_scratch
|
|
import tools.notify as _mod_notify
|
|
import tools.agent_notes as _mod_agent_notes
|
|
import tools.agents as _mod_agents
|
|
import tools.homeassistant as _mod_homeassistant
|
|
|
|
# ── Tool categories — used by the Model Registry UI for grouped checkboxes ───
|
|
|
|
TOOL_CATEGORIES: dict[str, list[str]] = {
|
|
"Web": ["web_search", "http_fetch", "web_read", "http_post"],
|
|
"Project Files": ["project_file_read", "project_file_list", "file_stat", "file_grep", "file_diff", "file_syntax_check"],
|
|
"System Files": ["file_read", "file_list", "file_write", "session_read", "session_search"],
|
|
"Shell": ["shell_exec", "claude_allow_dir"],
|
|
"System": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update"],
|
|
"Tasks": ["task_list", "task_create", "task_update", "task_complete"],
|
|
"Cron": ["cron_list", "cron_add", "cron_remove", "cron_toggle"],
|
|
"Reminders": ["reminders_add", "reminders_list", "reminders_remove", "reminders_clear"],
|
|
"Scratchpad": ["scratch_read", "scratch_write", "scratch_append", "scratch_clear"],
|
|
"Notifications": ["web_push", "email_send", "nc_talk_send", "nc_talk_history"],
|
|
"Aether Journals": [
|
|
"ae_journal_list", "ae_journal_search",
|
|
"ae_journal_entries_list", "ae_journal_entry_read",
|
|
"ae_journal_entry_create", "ae_journal_entry_update",
|
|
"ae_journal_entry_disable", "ae_journal_entry_append",
|
|
"ae_journal_entry_prepend",
|
|
],
|
|
"Aether Tasks": ["ae_task_list"],
|
|
"Agent Notes": ["agent_notes_read", "agent_notes_write", "agent_notes_append", "agent_notes_clear"],
|
|
"Agents": ["spawn_agent"],
|
|
"Home Assistant": ["ha_get_state", "ha_get_states", "ha_call_service"],
|
|
}
|
|
|
|
# ── Callable registry ─────────────────────────────────────────────────────────
|
|
|
|
_CALLABLES: dict[str, callable] = {
|
|
"web_search": _web_search,
|
|
"http_fetch": _http_fetch,
|
|
"web_read": _web_read,
|
|
"http_post": _http_post,
|
|
"ae_journal_list": _ae_journal_list,
|
|
"ae_journal_search": _ae_journal_search,
|
|
"ae_journal_entry_read": _ae_journal_entry_read,
|
|
"ae_journal_entries_list": _ae_journal_entries_list,
|
|
"ae_journal_entry_create": _ae_journal_entry_create,
|
|
"ae_journal_entry_update": _ae_journal_entry_update,
|
|
"ae_journal_entry_disable": _ae_journal_entry_disable,
|
|
"ae_journal_entry_append": _ae_journal_entry_append,
|
|
"ae_journal_entry_prepend": _ae_journal_entry_prepend,
|
|
"ae_task_list": _ae_task_list,
|
|
"project_file_read": _project_file_read,
|
|
"project_file_list": _project_file_list,
|
|
"file_stat": _file_stat,
|
|
"file_grep": _file_grep,
|
|
"file_diff": _file_diff,
|
|
"file_syntax_check": _file_syntax_check,
|
|
"file_read": _file_read,
|
|
"file_list": _file_list,
|
|
"file_write": _file_write,
|
|
"session_read": _session_read,
|
|
"session_search": _session_search,
|
|
"shell_exec": _shell_exec,
|
|
"claude_allow_dir": _claude_allow_dir,
|
|
"cortex_restart": _cortex_restart,
|
|
"cortex_logs": _cortex_logs,
|
|
"cortex_status": _cortex_status,
|
|
"cortex_update": _cortex_update,
|
|
"task_list": _task_list,
|
|
"task_create": _task_create,
|
|
"task_update": _task_update,
|
|
"task_complete": _task_complete,
|
|
"cron_list": _cron_list,
|
|
"cron_add": _cron_add,
|
|
"cron_remove": _cron_remove,
|
|
"cron_toggle": _cron_toggle,
|
|
"reminders_add": _reminders_add,
|
|
"reminders_list": _reminders_list,
|
|
"reminders_remove": _reminders_remove,
|
|
"reminders_clear": _reminders_clear,
|
|
"scratch_read": _scratch_read,
|
|
"scratch_write": _scratch_write,
|
|
"scratch_append": _scratch_append,
|
|
"scratch_clear": _scratch_clear,
|
|
"email_send": _email_send,
|
|
"nc_talk_send": _nc_talk_send,
|
|
"web_push": _web_push,
|
|
"nc_talk_history": _nc_talk_history,
|
|
"agent_notes_read": _agent_notes_read,
|
|
"agent_notes_write": _agent_notes_write,
|
|
"agent_notes_append": _agent_notes_append,
|
|
"agent_notes_clear": _agent_notes_clear,
|
|
"spawn_agent": _spawn_agent,
|
|
"ha_get_state": _ha_get_state,
|
|
"ha_get_states": _ha_get_states,
|
|
"ha_call_service": _ha_call_service,
|
|
}
|
|
|
|
# ── Role-based access control ─────────────────────────────────────────────────
|
|
|
|
# Minimum role required to use each tool. Unlisted tools default to "user".
|
|
TOOL_ROLES: dict[str, str] = {
|
|
"shell_exec": "admin",
|
|
"claude_allow_dir": "admin",
|
|
"cortex_restart": "admin",
|
|
"cortex_logs": "admin",
|
|
"cortex_status": "admin",
|
|
"cortex_update": "admin",
|
|
"file_read": "admin",
|
|
"file_list": "admin",
|
|
"file_write": "admin",
|
|
"ae_task_list": "admin",
|
|
"spawn_agent": "admin",
|
|
"email_send": "admin",
|
|
"nc_talk_send": "admin",
|
|
"http_post": "admin",
|
|
"nc_talk_history": "admin",
|
|
"ha_call_service": "admin",
|
|
}
|
|
|
|
# Tools that require explicit user confirmation before executing.
|
|
CONFIRM_REQUIRED: set[str] = {
|
|
"cortex_restart",
|
|
"cortex_update",
|
|
"file_write",
|
|
"shell_exec",
|
|
"cron_remove",
|
|
"reminders_clear",
|
|
"http_post",
|
|
"ha_call_service",
|
|
}
|
|
|
|
# Security risk ratings — informational for now; will drive auto-allow tiers later.
|
|
# Unlisted tools default to "medium".
|
|
#
|
|
# low — read-only, sandboxed, no external side effects
|
|
# medium — writes to local/controlled data, or reads beyond project scope,
|
|
# or sends notifications to the same user
|
|
# high — affects external systems, physical devices, other users,
|
|
# or the host process/filesystem in ways that are hard to reverse
|
|
TOOL_RISK: dict[str, str] = {
|
|
# Web — read-only fetches are low; posting to external services is high
|
|
"web_search": "low",
|
|
"http_fetch": "low",
|
|
"web_read": "low",
|
|
"http_post": "high",
|
|
|
|
# Project Files — all read-only and project-sandboxed
|
|
"project_file_read": "low",
|
|
"project_file_list": "low",
|
|
"file_stat": "low",
|
|
"file_grep": "low",
|
|
"file_diff": "low",
|
|
"file_syntax_check": "low",
|
|
|
|
# System Files — reads beyond project scope are medium; writes are high
|
|
"file_read": "medium",
|
|
"file_list": "medium",
|
|
"file_write": "high",
|
|
"session_read": "low",
|
|
"session_search": "low",
|
|
|
|
# Shell — arbitrary execution and permission changes are high
|
|
"shell_exec": "high",
|
|
"claude_allow_dir": "high",
|
|
|
|
# System — read-only status is low; restart/update affect the live service
|
|
"cortex_logs": "low",
|
|
"cortex_status": "low",
|
|
"cortex_restart": "high",
|
|
"cortex_update": "high",
|
|
|
|
# Tasks — local persona data, all reversible
|
|
"task_list": "low",
|
|
"task_create": "low",
|
|
"task_update": "low",
|
|
"task_complete": "low",
|
|
|
|
# Cron — list is low; add/remove/toggle affect scheduled behavior
|
|
"cron_list": "low",
|
|
"cron_add": "medium",
|
|
"cron_remove": "medium",
|
|
"cron_toggle": "medium",
|
|
|
|
# Reminders — single-item ops are low; clear-all is medium
|
|
"reminders_add": "low",
|
|
"reminders_list": "low",
|
|
"reminders_remove": "low",
|
|
"reminders_clear": "medium",
|
|
|
|
# Scratchpad — local persona file, ephemeral by design
|
|
"scratch_read": "low",
|
|
"scratch_write": "low",
|
|
"scratch_append": "low",
|
|
"scratch_clear": "low",
|
|
|
|
# Notifications — push to same user is medium; external messages are high
|
|
"web_push": "medium",
|
|
"nc_talk_send": "high",
|
|
"nc_talk_history": "low",
|
|
"email_send": "high",
|
|
|
|
# Aether Journals — reads are low; writes to external DB are medium
|
|
"ae_journal_list": "low",
|
|
"ae_journal_search": "low",
|
|
"ae_journal_entries_list": "low",
|
|
"ae_journal_entry_read": "low",
|
|
"ae_journal_entry_create": "medium",
|
|
"ae_journal_entry_update": "medium",
|
|
"ae_journal_entry_disable": "medium",
|
|
"ae_journal_entry_append": "medium",
|
|
"ae_journal_entry_prepend": "medium",
|
|
|
|
# Aether Tasks
|
|
"ae_task_list": "low",
|
|
|
|
# Agent Notes — local persona file
|
|
"agent_notes_read": "low",
|
|
"agent_notes_write": "low",
|
|
"agent_notes_append": "low",
|
|
"agent_notes_clear": "low",
|
|
|
|
# Agents — spawning a subprocess with broad permissions is high
|
|
"spawn_agent": "high",
|
|
|
|
# Home Assistant — reads are low; controlling physical devices is high
|
|
"ha_get_state": "low",
|
|
"ha_get_states": "low",
|
|
"ha_call_service": "high",
|
|
}
|
|
|
|
_RISK_RANK: dict[str, int] = {"low": 0, "medium": 1, "high": 2}
|
|
|
|
_ROLE_RANK: dict[str, int] = {"user": 0, "admin": 1}
|
|
|
|
|
|
def _role_allowed(tool_name: str, role: str) -> bool:
|
|
required = TOOL_ROLES.get(tool_name, "user")
|
|
return _ROLE_RANK.get(role, 0) >= _ROLE_RANK.get(required, 0)
|
|
|
|
|
|
# ── Declaration assembly ──────────────────────────────────────────────────────
|
|
|
|
_ALL_DECLARATIONS: list[types.FunctionDeclaration] = (
|
|
_mod_web.DECLARATIONS
|
|
+ _mod_files.DECLARATIONS
|
|
+ _mod_system.DECLARATIONS
|
|
+ _mod_tasks.DECLARATIONS
|
|
+ _mod_cron.DECLARATIONS
|
|
+ _mod_reminders.DECLARATIONS
|
|
+ _mod_scratch.DECLARATIONS
|
|
+ _mod_notify.DECLARATIONS
|
|
+ _mod_ae_knowledge.DECLARATIONS
|
|
+ _mod_ae_tasks.DECLARATIONS
|
|
+ _mod_agent_notes.DECLARATIONS
|
|
+ _mod_agents.DECLARATIONS
|
|
+ _mod_homeassistant.DECLARATIONS
|
|
)
|
|
|
|
# Full Gemini Tool object (all tools — use get_tools_for_role() in production)
|
|
TOOL_DECLARATIONS = [types.Tool(function_declarations=_ALL_DECLARATIONS)]
|
|
|
|
|
|
# ── Tool dispatch ─────────────────────────────────────────────────────────────
|
|
|
|
async def call_tool(name: str, args: dict, callables: dict | None = None) -> str:
|
|
"""Dispatch a tool call by name. Returns result as a string.
|
|
|
|
Pass `callables` (from get_tools_for_role) to enforce role restrictions.
|
|
Falls back to the full _CALLABLES dict if omitted.
|
|
|
|
Every call is recorded to the tool audit log (tool_audit.py).
|
|
"""
|
|
import asyncio
|
|
import tool_audit
|
|
from persona import get_user
|
|
|
|
user = get_user() or "unknown"
|
|
dispatch = callables if callables is not None else _CALLABLES
|
|
fn = dispatch.get(name)
|
|
|
|
if fn is None:
|
|
asyncio.create_task(tool_audit.record(user, name, args, "denied"))
|
|
return f"Tool not available or access denied: {name}"
|
|
|
|
try:
|
|
result = await fn(**args)
|
|
asyncio.create_task(tool_audit.record(user, name, args, "ok", result))
|
|
return result
|
|
except Exception as e:
|
|
asyncio.create_task(tool_audit.record(user, name, args, "error", str(e)))
|
|
raise
|
|
|
|
|
|
# ── OpenAI JSON Schema conversion ────────────────────────────────────────────
|
|
|
|
_GEMINI_TYPE_TO_JSON = {
|
|
"OBJECT": "object",
|
|
"STRING": "string",
|
|
"INTEGER": "integer",
|
|
"NUMBER": "number",
|
|
"BOOLEAN": "boolean",
|
|
"ARRAY": "array",
|
|
}
|
|
|
|
|
|
def _schema_to_json(schema) -> dict:
|
|
"""Recursively convert a Gemini types.Schema to a JSON Schema dict."""
|
|
type_name = getattr(getattr(schema, "type", None), "name", "STRING")
|
|
result: dict = {"type": _GEMINI_TYPE_TO_JSON.get(type_name, "string")}
|
|
|
|
if getattr(schema, "description", None):
|
|
result["description"] = schema.description
|
|
|
|
props = getattr(schema, "properties", None) or {}
|
|
if result["type"] == "object":
|
|
result["properties"] = {k: _schema_to_json(v) for k, v in props.items()}
|
|
|
|
req = getattr(schema, "required", None)
|
|
if req:
|
|
result["required"] = list(req)
|
|
|
|
return result
|
|
|
|
|
|
def _build_openai_tools() -> list[dict]:
|
|
out = []
|
|
for decl in _ALL_DECLARATIONS:
|
|
params = (
|
|
_schema_to_json(decl.parameters)
|
|
if decl.parameters
|
|
else {"type": "object", "properties": {}}
|
|
)
|
|
out.append({
|
|
"type": "function",
|
|
"function": {
|
|
"name": decl.name,
|
|
"description": decl.description or "",
|
|
"parameters": params,
|
|
},
|
|
})
|
|
return out
|
|
|
|
|
|
# OpenAI-format tool list — all tools (use get_openai_tools_for_role() in production)
|
|
OPENAI_TOOL_SCHEMAS: list[dict] = _build_openai_tools()
|
|
|
|
|
|
# ── Role-filtered tool access ─────────────────────────────────────────────────
|
|
|
|
def _apply_risk_policy(
|
|
allowed: set[str],
|
|
max_risk: str | None,
|
|
whitelist: list[str] | None,
|
|
blacklist: list[str] | None,
|
|
) -> set[str]:
|
|
"""Apply risk-level filtering on top of an already role-gated allowed set.
|
|
|
|
Filtering order (each step can only restrict or restore within what the
|
|
role already permits — risk policy can never elevate above role):
|
|
|
|
1. max_risk auto-include: keep tools whose risk ≤ max_risk
|
|
2. whitelist union: force-add specific tools (still role-gated)
|
|
3. blacklist subtract: force-remove specific tools
|
|
|
|
When max_risk is None, all role-allowed tools remain (no risk filter).
|
|
"""
|
|
if max_risk is not None:
|
|
max_rank = _RISK_RANK.get(max_risk, 2)
|
|
auto = {n for n in allowed if _RISK_RANK.get(TOOL_RISK.get(n, "medium"), 1) <= max_rank}
|
|
extra = {n for n in (whitelist or []) if n in allowed}
|
|
allowed = (auto | extra)
|
|
if blacklist:
|
|
allowed -= set(blacklist)
|
|
return allowed
|
|
|
|
|
|
def get_tools_for_role(
|
|
role: str,
|
|
tool_list: list[str] | None = None,
|
|
max_risk: str | None = None,
|
|
whitelist: list[str] | None = None,
|
|
blacklist: list[str] | None = None,
|
|
) -> tuple[list, dict]:
|
|
"""Return (gemini_tool_declarations, callables_dict) filtered to what the role can use.
|
|
|
|
role — user access level ("user" | "admin"); gates admin-only tools
|
|
tool_list — optional model-level allow-list; intersected so it can only restrict
|
|
max_risk — auto-include tools at/below this risk level ("low"|"medium"|"high")
|
|
whitelist — force-include specific tools above max_risk (still role-gated)
|
|
blacklist — force-exclude specific tools regardless of max_risk
|
|
"""
|
|
allowed = {name for name in _CALLABLES if _role_allowed(name, role)}
|
|
allowed = _apply_risk_policy(allowed, max_risk, whitelist, blacklist)
|
|
if tool_list is not None:
|
|
allowed &= set(tool_list)
|
|
decls = [d for d in _ALL_DECLARATIONS if d.name in allowed]
|
|
callables = {k: v for k, v in _CALLABLES.items() if k in allowed}
|
|
return [types.Tool(function_declarations=decls)], callables
|
|
|
|
|
|
def get_openai_tools_for_role(
|
|
role: str,
|
|
tool_list: list[str] | None = None,
|
|
max_risk: str | None = None,
|
|
whitelist: list[str] | None = None,
|
|
blacklist: list[str] | None = None,
|
|
) -> list[dict]:
|
|
"""Return OpenAI tool schemas filtered to what the role can use.
|
|
|
|
role — user access level ("user" | "admin")
|
|
tool_list — optional model-level allow-list
|
|
max_risk — auto-include tools at/below this risk level
|
|
whitelist — force-include specific tools above max_risk
|
|
blacklist — force-exclude specific tools
|
|
"""
|
|
allowed = {name for name in _CALLABLES if _role_allowed(name, role)}
|
|
allowed = _apply_risk_policy(allowed, max_risk, whitelist, blacklist)
|
|
if tool_list is not None:
|
|
allowed &= set(tool_list)
|
|
return [t for t in OPENAI_TOOL_SCHEMAS if t["function"]["name"] in allowed]
|