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

@@ -45,6 +45,10 @@ from tools.scratch import (
scratch_append as _scratch_append,
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_task_list": _ae_task_list,
"file_read": _file_read,
"file_list": _file_list,
"file_write": _file_write,
"claude_allow_dir": _claude_allow_dir,
"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_create": _task_create,
"task_update": _task_update,
@@ -640,46 +650,219 @@ _scratch_clear_declaration = types.FunctionDeclaration(
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
TOOL_DECLARATIONS = [
types.Tool(function_declarations=[
_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,
_claude_allow_dir_declaration,
_shell_exec_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,
])
_cortex_logs_declaration = types.FunctionDeclaration(
name="cortex_logs",
description=(
"Fetch recent lines from the Cortex systemd service journal. "
"Use for debugging errors, checking startup status, or reviewing recent activity. "
"ADMIN ONLY."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"lines": types.Schema(
type=types.Type.INTEGER,
description="Number of log lines to return (default 50, max 200)",
),
},
),
)
_http_fetch_declaration = types.FunctionDeclaration(
name="http_fetch",
description=(
"Fetch a specific URL and return the response. Unlike web_search, this hits "
"a direct URL — useful for health checks, JSON API endpoints, webhook testing, "
"or reading a specific page when you already know the URL. "
"Response body is capped at 8 KB."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"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."""
fn = _CALLABLES.get(name)
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.
"""
dispatch = callables if callables is not None else _CALLABLES
fn = dispatch.get(name)
if fn is None:
return f"Unknown tool: {name}"
return f"Tool not available or access denied: {name}"
return await fn(**args)
@@ -718,9 +901,9 @@ def _schema_to_json(schema) -> 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 = []
for decl in TOOL_DECLARATIONS[0].function_declarations:
for decl in _ALL_DECLARATIONS:
params = (
_schema_to_json(decl.parameters)
if decl.parameters
@@ -737,5 +920,27 @@ def _build_openai_tools() -> list[dict]:
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()
# ---------------------------------------------------------------------------
# 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:
continue
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.
These tools affect the host system directly. Use with care.
cortex_restart and cortex_logs require admin role.
"""
import asyncio
import logging
import os
import subprocess
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:
logger.error("shell_exec error: %s", 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.
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.
Web toolssearch (DuckDuckGo) and direct HTTP fetch.
"""
import asyncio
import logging
import httpx
from config import settings
logger = logging.getLogger(__name__)
@@ -48,3 +48,29 @@ def _sync_search(query: str, max_results: int) -> list[dict]:
except Exception as e:
logger.warning("DuckDuckGo search error: %s", e)
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}"