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]