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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user