Files
Cortex-Inara/cortex/tools/__init__.py
Scott Idem 69ec2f667d feat: tool risk policy UI + wiring through all orchestrators
- New /settings/tools page: max_risk selector (low/medium/high) + per-tool
  override dropdowns (Default / Force include / Force exclude) for all 58 tools
  grouped by category with color-coded risk badges; JS updates Auto status live
- get_tools_for_role() + get_openai_tools_for_role() now accept max_risk,
  whitelist, blacklist; _apply_risk_policy() handles the filtering logic
- get_risk_policy() helper in auth_utils reads from tool_policy.json
- Risk policy wired through orchestrator.py, openai_orchestrator.py,
  orchestrator_engine.py, nextcloud_talk.py, homeassistant.py
- Tools nav link added to settings.html and notifications.html
- CLAUDE.md and ARCH__SYSTEM.md updated: tool count 50→58, risk system docs,
  tool access control three-layer model documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:45:04 -04:00

518 lines
20 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_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_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_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_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]