feat: role-based tool access, confirmation gates, and new orchestrator tools

- auth_utils: get_user_role() reads role from auth.json (admin|user, default user)
- manage_passwords: new `role` command to promote/demote users (admin-only by convention)
- tools/__init__: TOOL_ROLES map, CONFIRM_REQUIRED set, get_tools_for_role(),
  get_openai_tools_for_role() — both orchestrators now filter tools by caller's role
- tools/system: cortex_restart (detached subprocess, 5s delay), cortex_logs (admin-only)
- tools/web: http_fetch — direct URL fetch, distinct from web_search
- tools/files: file_list (directory listing), file_write (restricted paths, admin-only)
- tools/notify: nc_talk_send — proactive outbound via notification.py
- orchestrator_engine + openai_orchestrator: user_role param; CONFIRM_REQUIRED tools
  return a confirmation-request result instead of executing — loop breaks after Claude
  asks user to confirm in a follow-up message
- home/scott/auth.json: role set to admin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-29 19:23:53 -04:00
parent 1603ad5124
commit 334e7f0dea
10 changed files with 581 additions and 87 deletions

View File

@@ -115,6 +115,16 @@ def get_user_gemini_key(username: str) -> str | None:
return _read_auth(username).get("gemini_api_key") or None return _read_auth(username).get("gemini_api_key") or None
def get_user_role(username: str) -> str:
"""Return the user's role: 'admin' or 'user' (default).
Role is stored as auth.json["role"]. Any unrecognised value falls back to 'user'.
Set via: manage_passwords.py role <username> admin|user
"""
role = _read_auth(username).get("role", "user")
return role if role in ("admin", "user") else "user"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# JWT helpers # JWT helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -170,6 +170,25 @@ def cmd_google_add(args):
print(f"They can now sign in at {settings.cortex_base_url}/login using that Google account.") print(f"They can now sign in at {settings.cortex_base_url}/login using that Google account.")
def cmd_role(args):
if len(args) < 2:
print("Usage: manage_passwords.py role <username> admin|user")
sys.exit(1)
username, role = args[0], args[1].lower().strip()
if role not in ("admin", "user"):
print("Role must be 'admin' or 'user'.")
sys.exit(1)
from auth_utils import _read_auth, _write_auth
data = _read_auth(username)
if not data:
print(f"User '{username}' not found — no auth.json.")
sys.exit(1)
old_role = data.get("role", "user")
data["role"] = role
_write_auth(username, data)
print(f"Role for '{username}': {old_role}{role}")
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) < 2: if len(sys.argv) < 2:
print(__doc__) print(__doc__)
@@ -190,6 +209,8 @@ if __name__ == "__main__":
cmd_invite(rest) cmd_invite(rest)
elif command == "google-add": elif command == "google-add":
cmd_google_add(rest) cmd_google_add(rest)
elif command == "role":
cmd_role(rest)
else: else:
print(f"Unknown command: {command}") print(f"Unknown command: {command}")
print(__doc__) print(__doc__)

View File

@@ -25,7 +25,7 @@ from openai import AsyncOpenAI
from config import settings from config import settings
from orchestrator_engine import OrchestratorResult from orchestrator_engine import OrchestratorResult
from tools import OPENAI_TOOL_SCHEMAS, call_tool from tools import OPENAI_TOOL_SCHEMAS, call_tool, get_openai_tools_for_role, CONFIRM_REQUIRED
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -44,6 +44,7 @@ async def run(
session_messages: list[dict] | None = None, session_messages: list[dict] | None = None,
model_cfg: dict | None = None, model_cfg: dict | None = None,
respond_with_final: bool = True, respond_with_final: bool = True,
user_role: str = "user",
) -> OrchestratorResult: ) -> OrchestratorResult:
""" """
Run a tool-enabled task using an OpenAI-compatible API. Run a tool-enabled task using an OpenAI-compatible API.
@@ -93,6 +94,9 @@ async def run(
) )
messages.append({"role": "user", "content": task}) messages.append({"role": "user", "content": task})
active_tools = get_openai_tools_for_role(user_role)
active_callables: dict | None = None # resolved lazily below
tool_call_log: list[dict] = [] tool_call_log: list[dict] = []
final_response = "" final_response = ""
@@ -103,7 +107,7 @@ async def run(
response = await client.chat.completions.create( response = await client.chat.completions.create(
model=model_name, model=model_name,
messages=messages, messages=messages,
tools=OPENAI_TOOL_SCHEMAS, tools=active_tools,
tool_choice="auto", tool_choice="auto",
) )
@@ -130,39 +134,54 @@ async def run(
messages.append(assistant_msg) messages.append(assistant_msg)
if choice.finish_reason == "tool_calls" and msg.tool_calls: if choice.finish_reason == "tool_calls" and msg.tool_calls:
# Execute all tool calls in parallel, then feed results back confirm_requested = False
tool_tasks = [
_execute_tool(tc.function.name, tc.function.arguments)
for tc in msg.tool_calls
]
results = await asyncio.gather(*tool_tasks, return_exceptions=True)
for tc, result in zip(msg.tool_calls, results):
result_str = (
str(result)
if not isinstance(result, Exception)
else f"Tool error: {result}"
)
logger.info("Tool %s%d chars", tc.function.name, len(result_str))
for tc in msg.tool_calls:
name = tc.function.name
try: try:
args_parsed = json.loads(tc.function.arguments) args_parsed = json.loads(tc.function.arguments)
except json.JSONDecodeError: except json.JSONDecodeError:
args_parsed = {"raw": tc.function.arguments} args_parsed = {"raw": tc.function.arguments}
tool_call_log.append({ if name in CONFIRM_REQUIRED:
"tool": tc.function.name, args_str = json.dumps(args_parsed, indent=2) if args_parsed else "(no arguments)"
"args": args_parsed, result_str = (
"result": result_str, f"⚠️ CONFIRMATION REQUIRED ⚠️\n"
}) f"Tool: {name}\nArguments:\n{args_str}\n\n"
f"Do NOT call this tool again. Tell the user exactly what you were "
f"about to do, explain the potential impact, and ask them to confirm "
f"by sending a follow-up message before you proceed."
)
confirm_requested = True
logger.info("Tool %s blocked — confirmation required", name)
else:
result_str = await _execute_tool(name, tc.function.arguments, user_role)
logger.info("Tool %s%d chars", name, len(result_str))
# Tool result message — tools array must be re-sent on every request tool_call_log.append({
"tool": name,
"args": args_parsed,
"result": "[awaiting confirmation]" if name in CONFIRM_REQUIRED else result_str,
})
messages.append({ messages.append({
"role": "tool", "role": "tool",
"tool_call_id": tc.id, "tool_call_id": tc.id,
"content": result_str, "content": result_str,
}) })
if confirm_requested:
# One more model round to produce the confirmation-request message, then stop.
conf_resp = await client.chat.completions.create(
model=model_name,
messages=messages,
tools=active_tools,
tool_choice="none",
)
final_response = conf_resp.choices[0].message.content or (
"This action requires your explicit confirmation before it can proceed."
)
break
else: else:
# finish_reason == "stop" (or no tool_calls) — model is done # finish_reason == "stop" (or no tool_calls) — model is done
final_response = msg.content or "" final_response = msg.content or ""
@@ -194,14 +213,16 @@ async def run(
) )
async def _execute_tool(name: str, arguments_json: str) -> str: async def _execute_tool(name: str, arguments_json: str, user_role: str = "user") -> str:
"""Parse tool arguments and execute, returning a string result.""" """Parse tool arguments and execute with role-filtered callables."""
from tools import get_tools_for_role
_, callables = get_tools_for_role(user_role)
try: try:
args = json.loads(arguments_json) args = json.loads(arguments_json)
except json.JSONDecodeError: except json.JSONDecodeError:
args = {} args = {}
try: try:
return await call_tool(name, args) return await call_tool(name, args, callables)
except Exception as e: except Exception as e:
logger.warning("Tool %s failed: %s", name, e) logger.warning("Tool %s failed: %s", name, e)
return f"Tool error: {e}" return f"Tool error: {e}"

View File

@@ -16,6 +16,7 @@ calls llm_client.complete() directly, which is faster and has no orchestration o
""" """
import asyncio import asyncio
import json
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -24,7 +25,7 @@ from google.genai import types
from config import settings from config import settings
from llm_client import complete from llm_client import complete
from tools import TOOL_DECLARATIONS, call_tool from tools import TOOL_DECLARATIONS, call_tool, get_tools_for_role, CONFIRM_REQUIRED
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -59,6 +60,7 @@ async def run(
gemini_api_key: str | None = None, gemini_api_key: str | None = None,
model_name: str | None = None, model_name: str | None = None,
response_role: str = "chat", response_role: str = "chat",
user_role: str = "user",
) -> OrchestratorResult: ) -> OrchestratorResult:
""" """
Run the full orchestration loop for a task. Run the full orchestration loop for a task.
@@ -89,6 +91,8 @@ async def run(
types.Content(role="user", parts=[types.Part(text=task_with_context)]) types.Content(role="user", parts=[types.Part(text=task_with_context)])
] ]
tool_declarations, tool_callables = get_tools_for_role(user_role)
tool_call_log: list[dict] = [] tool_call_log: list[dict] = []
gemini_summary = "" gemini_summary = ""
@@ -101,7 +105,7 @@ async def run(
model=model_name or settings.orchestrator_model, model=model_name or settings.orchestrator_model,
contents=contents, contents=contents,
config=types.GenerateContentConfig( config=types.GenerateContentConfig(
tools=TOOL_DECLARATIONS, tools=tool_declarations,
system_instruction=_ORCHESTRATOR_SYSTEM, system_instruction=_ORCHESTRATOR_SYSTEM,
), ),
) )
@@ -127,29 +131,39 @@ async def run(
# Add Gemini's response (with function calls) to the conversation # Add Gemini's response (with function calls) to the conversation
contents.append(candidate.content) contents.append(candidate.content)
# Execute all tool calls in parallel # Execute tool calls — check confirmation requirement before calling
tool_tasks = [
_execute_tool(fc.function_call.name, dict(fc.function_call.args))
for fc in tool_call_parts
]
tool_results = await asyncio.gather(*tool_tasks, return_exceptions=True)
# Build function response parts and update log
response_parts: list[types.Part] = [] response_parts: list[types.Part] = []
for fc_part, result in zip(tool_call_parts, tool_results): confirm_requested = False
for fc_part in tool_call_parts:
fc = fc_part.function_call fc = fc_part.function_call
result_str = str(result) if not isinstance(result, Exception) else f"Error: {result}" name = fc.name
logger.info("Tool %s%d chars", fc.name, len(result_str)) args = dict(fc.args)
if name in CONFIRM_REQUIRED:
args_str = json.dumps(args, indent=2) if args else "(no arguments)"
result_str = (
f"⚠️ CONFIRMATION REQUIRED ⚠️\n"
f"Tool: {name}\nArguments:\n{args_str}\n\n"
f"Do NOT call this tool again. Tell the user exactly what you were "
f"about to do, explain the potential impact, and ask them to confirm "
f"by sending a follow-up message before you proceed."
)
confirm_requested = True
logger.info("Tool %s blocked — confirmation required", name)
else:
result_str = await _execute_tool(name, args, tool_callables)
logger.info("Tool %s%d chars", name, len(result_str))
tool_call_log.append({ tool_call_log.append({
"tool": fc.name, "tool": name,
"args": dict(fc.args), "args": args,
"result": result_str, "result": "[awaiting confirmation]" if name in CONFIRM_REQUIRED else result_str,
}) })
response_parts.append( response_parts.append(
types.Part( types.Part(
function_response=types.FunctionResponse( function_response=types.FunctionResponse(
name=fc.name, name=name,
response={"result": result_str}, response={"result": result_str},
) )
) )
@@ -157,6 +171,28 @@ async def run(
contents.append(types.Content(role="user", parts=response_parts)) contents.append(types.Content(role="user", parts=response_parts))
if confirm_requested:
# Allow one more Gemini round to produce the confirmation-request message,
# then break — tool is not executed until user confirms in a follow-up.
conf_response = await asyncio.to_thread(
client.models.generate_content,
model=model_name or settings.orchestrator_model,
contents=contents,
config=types.GenerateContentConfig(
tools=tool_declarations,
system_instruction=_ORCHESTRATOR_SYSTEM,
),
)
conf_parts = (
conf_response.candidates[0].content.parts
if conf_response.candidates and conf_response.candidates[0].content
else []
)
gemini_summary = "".join(
p.text for p in conf_parts if hasattr(p, "text") and p.text
).strip() or "This action requires your explicit confirmation before it can proceed."
break
else: else:
# Hit the round limit — use whatever Gemini produced last # Hit the round limit — use whatever Gemini produced last
logger.warning("Orchestrator hit max rounds (%d)", settings.orchestrator_max_rounds) logger.warning("Orchestrator hit max rounds (%d)", settings.orchestrator_max_rounds)
@@ -192,10 +228,10 @@ async def run(
) )
async def _execute_tool(name: str, args: dict) -> str: async def _execute_tool(name: str, args: dict, callables: dict | None = None) -> str:
"""Execute a single tool call, catching all exceptions.""" """Execute a single tool call, catching all exceptions."""
try: try:
return await call_tool(name, args) return await call_tool(name, args, callables)
except Exception as e: except Exception as e:
logger.warning("Tool %s failed: %s", name, e) logger.warning("Tool %s failed: %s", name, e)
return f"Tool error: {e}" return f"Tool error: {e}"

View File

@@ -18,7 +18,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel from pydantic import BaseModel
from auth_utils import get_user_gemini_key from auth_utils import get_user_gemini_key, get_user_role
from config import settings from config import settings
from context_loader import load_context from context_loader import load_context
from persona import set_context, validate as validate_persona from persona import set_context, validate as validate_persona
@@ -163,6 +163,8 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
# Choose engine based on the orchestrator role in the model registry # Choose engine based on the orchestrator role in the model registry
orch_model = model_registry.get_model_for_role(user, "orchestrator") orch_model = model_registry.get_model_for_role(user, "orchestrator")
user_role = get_user_role(user)
if orch_model and orch_model.get("type") == "local_openai": if orch_model and orch_model.get("type") == "local_openai":
result = await openai_orchestrator.run( result = await openai_orchestrator.run(
task=req.task, task=req.task,
@@ -170,6 +172,7 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
session_messages=session_messages, session_messages=session_messages,
model_cfg=orch_model, model_cfg=orch_model,
respond_with_final=req.respond_with_claude, respond_with_final=req.respond_with_claude,
user_role=user_role,
) )
else: else:
# Use the API key embedded in the resolved model config (V2 registry with # Use the API key embedded in the resolved model config (V2 registry with
@@ -186,6 +189,7 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
gemini_api_key=gemini_key, gemini_api_key=gemini_key,
model_name=orch_model.get("model_name") if orch_model else None, model_name=orch_model.get("model_name") if orch_model else None,
response_role=req.chat_role, response_role=req.chat_role,
user_role=user_role,
) )
# Save the turn to the session store so it survives a page refresh # Save the turn to the session store so it survives a page refresh

View File

@@ -45,6 +45,10 @@ from tools.scratch import (
scratch_append as _scratch_append, scratch_append as _scratch_append,
scratch_clear as _scratch_clear, scratch_clear as _scratch_clear,
) )
from tools.system import cortex_restart as _cortex_restart, cortex_logs as _cortex_logs
from tools.web import http_fetch as _http_fetch
from tools.files import file_list as _file_list, file_write as _file_write
from tools.notify import nc_talk_send as _nc_talk_send
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -285,8 +289,14 @@ _CALLABLES: dict[str, callable] = {
"ae_journal_entry_prepend": _ae_journal_entry_prepend, "ae_journal_entry_prepend": _ae_journal_entry_prepend,
"ae_task_list": _ae_task_list, "ae_task_list": _ae_task_list,
"file_read": _file_read, "file_read": _file_read,
"file_list": _file_list,
"file_write": _file_write,
"claude_allow_dir": _claude_allow_dir, "claude_allow_dir": _claude_allow_dir,
"shell_exec": _shell_exec, "shell_exec": _shell_exec,
"cortex_restart": _cortex_restart,
"cortex_logs": _cortex_logs,
"http_fetch": _http_fetch,
"nc_talk_send": _nc_talk_send,
"task_list": _task_list, "task_list": _task_list,
"task_create": _task_create, "task_create": _task_create,
"task_update": _task_update, "task_update": _task_update,
@@ -640,46 +650,219 @@ _scratch_clear_declaration = types.FunctionDeclaration(
parameters=types.Schema(type=types.Type.OBJECT, properties={}), parameters=types.Schema(type=types.Type.OBJECT, properties={}),
) )
_cortex_restart_declaration = types.FunctionDeclaration(
name="cortex_restart",
description=(
"Restart the Cortex service via systemd. Schedules a restart 5 seconds from now. "
"The current connection will drop — inform the user to refresh the page. "
"Use after config changes, memory edits, or when the service needs a fresh start. "
"ADMIN ONLY."
),
parameters=types.Schema(type=types.Type.OBJECT, properties={}),
)
# Gemini Tool object — pass this to GenerateContentConfig _cortex_logs_declaration = types.FunctionDeclaration(
TOOL_DECLARATIONS = [ name="cortex_logs",
types.Tool(function_declarations=[ description=(
_web_search_declaration, "Fetch recent lines from the Cortex systemd service journal. "
_ae_journal_list_declaration, "Use for debugging errors, checking startup status, or reviewing recent activity. "
_ae_journal_search_declaration, "ADMIN ONLY."
_ae_journal_entry_create_declaration, ),
_ae_journal_entry_update_declaration, parameters=types.Schema(
_ae_journal_entry_disable_declaration, type=types.Type.OBJECT,
_ae_journal_entry_append_declaration, properties={
_ae_journal_entry_prepend_declaration, "lines": types.Schema(
_ae_task_list_declaration, type=types.Type.INTEGER,
_file_read_declaration, description="Number of log lines to return (default 50, max 200)",
_claude_allow_dir_declaration, ),
_shell_exec_declaration, },
_task_list_declaration, ),
_task_create_declaration, )
_task_update_declaration,
_task_complete_declaration, _http_fetch_declaration = types.FunctionDeclaration(
_cron_list_declaration, name="http_fetch",
_cron_add_declaration, description=(
_cron_remove_declaration, "Fetch a specific URL and return the response. Unlike web_search, this hits "
_cron_toggle_declaration, "a direct URL — useful for health checks, JSON API endpoints, webhook testing, "
_reminders_add_declaration, "or reading a specific page when you already know the URL. "
_reminders_list_declaration, "Response body is capped at 8 KB."
_reminders_clear_declaration, ),
_scratch_read_declaration, parameters=types.Schema(
_scratch_write_declaration, type=types.Type.OBJECT,
_scratch_append_declaration, properties={
_scratch_clear_declaration, "url": types.Schema(type=types.Type.STRING, description="Full URL to fetch"),
]) "method": types.Schema(
type=types.Type.STRING,
description="HTTP method: GET (default), POST, HEAD",
),
"body": types.Schema(
type=types.Type.STRING,
description="Optional request body (for POST requests)",
),
"timeout": types.Schema(
type=types.Type.INTEGER,
description="Request timeout in seconds (default 15, max 60)",
),
},
required=["url"],
),
)
_file_list_declaration = types.FunctionDeclaration(
name="file_list",
description=(
"List the files and subdirectories in a directory. "
"Allowed paths: ~/agents_sync/, ~/OSIT_dev/, ~/DgrZone_Nextcloud/, ~/OSIT_Nextcloud/. "
"ADMIN ONLY."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"path": types.Schema(
type=types.Type.STRING,
description="Absolute or home-relative path to the directory",
),
},
required=["path"],
),
)
_file_write_declaration = types.FunctionDeclaration(
name="file_write",
description=(
"Write or append content to a file. "
"Write-allowed paths: ~/agents_sync/ and the Cortex home/ directory. "
"Creates parent directories if needed. "
"ADMIN ONLY. Requires user confirmation before executing."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"path": types.Schema(
type=types.Type.STRING,
description="Absolute or home-relative path to write to",
),
"content": types.Schema(
type=types.Type.STRING,
description="Content to write",
),
"mode": types.Schema(
type=types.Type.STRING,
description="'overwrite' (default, replaces file) or 'append' (adds to end)",
),
},
required=["path", "content"],
),
)
_nc_talk_send_declaration = types.FunctionDeclaration(
name="nc_talk_send",
description=(
"Send a proactive message to the user via their configured notification channel "
"(Nextcloud Talk by default). Use this to notify the user of completed background "
"tasks, important events, or anything they should know between sessions. "
"Requires notification_channel and notification_room set in channels.json."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"message": types.Schema(
type=types.Type.STRING,
description="The message to send to the user",
),
},
required=["message"],
),
)
# ---------------------------------------------------------------------------
# Role-based access control
# ---------------------------------------------------------------------------
# Minimum role required to use each tool. Unlisted tools default to "user".
TOOL_ROLES: dict[str, str] = {
# Admin-only — system-level or broad filesystem access
"shell_exec": "admin",
"claude_allow_dir": "admin",
"cortex_restart": "admin",
"cortex_logs": "admin",
"file_read": "admin",
"file_list": "admin",
"file_write": "admin",
"ae_task_list": "admin", # reads agents_sync kanban
}
# Tools that require explicit user confirmation before executing.
# The orchestrator injects a CONFIRMATION_REQUIRED result instead of calling
# the tool, prompting Claude to ask the user to confirm in a follow-up message.
CONFIRM_REQUIRED: set[str] = {
"cortex_restart",
"file_write",
"shell_exec",
"cron_remove",
"reminders_clear",
}
_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)
# Flat list of all declarations — single source of truth for both Gemini and OpenAI formats.
_ALL_DECLARATIONS: list[types.FunctionDeclaration] = [
_web_search_declaration,
_ae_journal_list_declaration,
_ae_journal_search_declaration,
_ae_journal_entry_create_declaration,
_ae_journal_entry_update_declaration,
_ae_journal_entry_disable_declaration,
_ae_journal_entry_append_declaration,
_ae_journal_entry_prepend_declaration,
_ae_task_list_declaration,
_file_read_declaration,
_file_list_declaration,
_file_write_declaration,
_claude_allow_dir_declaration,
_shell_exec_declaration,
_cortex_restart_declaration,
_cortex_logs_declaration,
_http_fetch_declaration,
_nc_talk_send_declaration,
_task_list_declaration,
_task_create_declaration,
_task_update_declaration,
_task_complete_declaration,
_cron_list_declaration,
_cron_add_declaration,
_cron_remove_declaration,
_cron_toggle_declaration,
_reminders_add_declaration,
_reminders_list_declaration,
_reminders_clear_declaration,
_scratch_read_declaration,
_scratch_write_declaration,
_scratch_append_declaration,
_scratch_clear_declaration,
] ]
# Full Gemini Tool object (all tools — use get_tools_for_role() in production)
TOOL_DECLARATIONS = [types.Tool(function_declarations=_ALL_DECLARATIONS)]
async def call_tool(name: str, args: dict) -> str:
"""Dispatch a tool call by name. Returns result as a string.""" async def call_tool(name: str, args: dict, callables: dict | None = None) -> str:
fn = _CALLABLES.get(name) """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.
"""
dispatch = callables if callables is not None else _CALLABLES
fn = dispatch.get(name)
if fn is None: if fn is None:
return f"Unknown tool: {name}" return f"Tool not available or access denied: {name}"
return await fn(**args) return await fn(**args)
@@ -718,9 +901,9 @@ def _schema_to_json(schema) -> dict:
def _build_openai_tools() -> list[dict]: def _build_openai_tools() -> list[dict]:
"""Convert TOOL_DECLARATIONS (Gemini format) to OpenAI tool schemas.""" """Convert _ALL_DECLARATIONS (Gemini format) to OpenAI tool schemas."""
out = [] out = []
for decl in TOOL_DECLARATIONS[0].function_declarations: for decl in _ALL_DECLARATIONS:
params = ( params = (
_schema_to_json(decl.parameters) _schema_to_json(decl.parameters)
if decl.parameters if decl.parameters
@@ -737,5 +920,27 @@ def _build_openai_tools() -> list[dict]:
return out return out
# OpenAI-format tool list — pass to client.chat.completions.create(tools=...) # OpenAI-format tool list — all tools (use get_openai_tools_for_role() in production)
OPENAI_TOOL_SCHEMAS: list[dict] = _build_openai_tools() OPENAI_TOOL_SCHEMAS: list[dict] = _build_openai_tools()
# ---------------------------------------------------------------------------
# Role-filtered tool access
# ---------------------------------------------------------------------------
def get_tools_for_role(role: str) -> tuple[list, dict]:
"""Return (gemini_tool_declarations, callables_dict) filtered to tools the role can use.
Usage in orchestrator:
tool_declarations, tool_callables = get_tools_for_role(user_role)
"""
allowed = {name for name in _CALLABLES if _role_allowed(name, role)}
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) -> list[dict]:
"""Return OpenAI tool schemas filtered to tools the role can use."""
allowed = {name for name in _CALLABLES if _role_allowed(name, role)}
return [t for t in OPENAI_TOOL_SCHEMAS if t["function"]["name"] in allowed]

View File

@@ -110,3 +110,105 @@ def _is_allowed(resolved: Path) -> bool:
except ValueError: except ValueError:
continue continue
return False return False
# Write is restricted to a tighter set of paths to limit blast radius.
_WRITE_ROOTS: list[Path] = [
Path.home() / "agents_sync",
]
def _is_write_allowed(resolved: Path) -> bool:
for root in _WRITE_ROOTS:
try:
resolved.relative_to(root)
return True
except ValueError:
continue
# Also allow the Cortex home/ directory (persona memory, tasks, etc.)
try:
from config import settings
cortex_home = settings.home_root()
resolved.relative_to(cortex_home)
return True
except (ValueError, Exception):
pass
return False
async def file_list(path: str) -> str:
"""List the contents of a directory.
Returns names of files and subdirectories with type indicators (/ for dirs).
Same allow-list as file_read.
"""
return await asyncio.to_thread(_sync_file_list, path)
def _sync_file_list(path: str) -> str:
try:
resolved = Path(path).expanduser().resolve()
except Exception as e:
return f"Invalid path: {e}"
if not _is_allowed(resolved):
allowed_str = ", ".join(str(r) for r in _ALLOWED_ROOTS)
return f"Access denied: {resolved}\nAllowed directories: {allowed_str}"
if not resolved.exists():
return f"Path not found: {resolved}"
if resolved.is_file():
return f"{resolved} is a file, not a directory. Use file_read to read it."
try:
entries = sorted(resolved.iterdir(), key=lambda e: (e.is_file(), e.name.lower()))
lines = []
for e in entries[:200]:
suffix = "/" if e.is_dir() else f" ({e.stat().st_size} bytes)" if e.is_file() else ""
lines.append(f"{e.name}{suffix}")
result = "\n".join(lines)
if len(entries) > 200:
result += f"\n… ({len(entries) - 200} more entries not shown)"
return f"Contents of {resolved}:\n\n{result}"
except Exception as e:
return f"Cannot list directory: {e}"
async def file_write(path: str, content: str, mode: str = "overwrite") -> str:
"""Write content to a file.
mode: 'overwrite' (default) replaces the file; 'append' adds to the end.
Write-allowed paths: ~/agents_sync/ and the Cortex home/ directory.
Parent directories are created if they don't exist.
"""
return await asyncio.to_thread(_sync_file_write, path, content, mode)
def _sync_file_write(path: str, content: str, mode: str) -> str:
try:
resolved = Path(path).expanduser().resolve()
except Exception as e:
return f"Invalid path: {e}"
if not _is_write_allowed(resolved):
return (
f"Write access denied: {resolved}\n"
f"Allowed write roots: ~/agents_sync/ and the Cortex home/ directory."
)
if mode not in ("overwrite", "append"):
return f"Invalid mode '{mode}' — use 'overwrite' or 'append'."
try:
resolved.parent.mkdir(parents=True, exist_ok=True)
if mode == "append":
with resolved.open("a", encoding="utf-8") as f:
f.write(content)
return f"Appended {len(content)} chars to {resolved}"
else:
resolved.write_text(content, encoding="utf-8")
return f"Wrote {len(content)} chars to {resolved}"
except Exception as e:
logger.error("file_write error for %s: %s", resolved, e)
return f"Write error: {e}"

28
cortex/tools/notify.py Normal file
View File

@@ -0,0 +1,28 @@
"""
Notification tools — proactively send messages to user channels.
nc_talk_send routes through notification.py → channels.json.
Requires notification_channel and notification_room set in the user's channels.json.
"""
import logging
from persona import get_user
logger = logging.getLogger(__name__)
async def nc_talk_send(message: str) -> str:
"""Send a message to the user via their configured notification channel.
Channel is resolved from the user's channels.json (notification_channel key).
Falls back to Nextcloud Talk if configured. No-op if no channel is set.
"""
from notification import notify
username = get_user()
try:
await notify(username, message)
return f"Message sent to {username}'s notification channel."
except Exception as e:
logger.warning("nc_talk_send error for %s: %s", username, e)
return f"Failed to send notification: {e}"

View File

@@ -2,11 +2,13 @@
System tools — local machine operations. System tools — local machine operations.
These tools affect the host system directly. Use with care. These tools affect the host system directly. Use with care.
cortex_restart and cortex_logs require admin role.
""" """
import asyncio import asyncio
import logging import logging
import os import os
import subprocess
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -83,3 +85,42 @@ async def shell_exec(command: str, working_dir: str | None = None, timeout: int
except Exception as e: except Exception as e:
logger.error("shell_exec error: %s", e) logger.error("shell_exec error: %s", e)
return f"Error: {e}" return f"Error: {e}"
async def cortex_restart() -> str:
"""Schedule a Cortex service restart 5 seconds from now.
Uses a detached subprocess so the restart survives the current process being
terminated by systemd. The calling session will drop — user should refresh.
"""
subprocess.Popen(
["bash", "-c", "sleep 5 && systemctl --user restart cortex"],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
)
logger.info("cortex_restart: restart scheduled in 5 seconds")
return (
"Cortex restart scheduled in 5 seconds. "
"The current connection will drop — please refresh the page after a moment."
)
async def cortex_logs(lines: int = 50) -> str:
"""Return recent lines from the Cortex systemd journal."""
n = min(max(int(lines), 1), 200)
try:
proc = await asyncio.create_subprocess_exec(
"journalctl", "--user", "-u", "cortex", f"-n{n}", "--no-pager",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15)
out = stdout.decode(errors="replace").strip()
return out or stderr.decode(errors="replace").strip() or "No log output."
except asyncio.TimeoutError:
return "Error: journalctl timed out"
except Exception as e:
logger.error("cortex_logs error: %s", e)
return f"Error: {e}"

View File

@@ -1,12 +1,12 @@
""" """
Web search tool — DuckDuckGo backend. Web toolssearch (DuckDuckGo) and direct HTTP fetch.
Uses the duckduckgo-search library. Set DDG_API_KEY in .env for a paid account
(higher rate limits). The free unauthenticated tier works for moderate usage.
""" """
import asyncio import asyncio
import logging import logging
import httpx
from config import settings from config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,3 +48,29 @@ def _sync_search(query: str, max_results: int) -> list[dict]:
except Exception as e: except Exception as e:
logger.warning("DuckDuckGo search error: %s", e) logger.warning("DuckDuckGo search error: %s", e)
return [] return []
async def http_fetch(
url: str,
method: str = "GET",
body: str | None = None,
timeout: int = 15,
) -> str:
"""Fetch a URL directly and return the response body.
Unlike web_search, this hits a specific URL — useful for health checks,
API probing, JSON endpoints, webhook testing, etc.
Response body is capped at 8 KB.
"""
method = method.upper()
timeout = min(max(int(timeout), 1), 60)
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
resp = await client.request(method, url, content=body)
body_text = resp.text[:8192]
return f"HTTP {resp.status_code} {resp.url}\n\n{body_text}"
except httpx.HTTPError as e:
return f"HTTP error: {e}"
except Exception as e:
logger.warning("http_fetch error for %s: %s", url, e)
return f"Error: {e}"