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

@@ -16,6 +16,7 @@ calls llm_client.complete() directly, which is faster and has no orchestration o
"""
import asyncio
import json
import logging
from dataclasses import dataclass, field
@@ -24,7 +25,7 @@ from google.genai import types
from config import settings
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__)
@@ -59,6 +60,7 @@ async def run(
gemini_api_key: str | None = None,
model_name: str | None = None,
response_role: str = "chat",
user_role: str = "user",
) -> OrchestratorResult:
"""
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)])
]
tool_declarations, tool_callables = get_tools_for_role(user_role)
tool_call_log: list[dict] = []
gemini_summary = ""
@@ -101,7 +105,7 @@ async def run(
model=model_name or settings.orchestrator_model,
contents=contents,
config=types.GenerateContentConfig(
tools=TOOL_DECLARATIONS,
tools=tool_declarations,
system_instruction=_ORCHESTRATOR_SYSTEM,
),
)
@@ -127,29 +131,39 @@ async def run(
# Add Gemini's response (with function calls) to the conversation
contents.append(candidate.content)
# Execute all tool calls in parallel
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
# Execute tool calls — check confirmation requirement before calling
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
result_str = str(result) if not isinstance(result, Exception) else f"Error: {result}"
logger.info("Tool %s%d chars", fc.name, len(result_str))
name = fc.name
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": fc.name,
"args": dict(fc.args),
"result": result_str,
"tool": name,
"args": args,
"result": "[awaiting confirmation]" if name in CONFIRM_REQUIRED else result_str,
})
response_parts.append(
types.Part(
function_response=types.FunctionResponse(
name=fc.name,
name=name,
response={"result": result_str},
)
)
@@ -157,6 +171,28 @@ async def run(
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:
# Hit the round limit — use whatever Gemini produced last
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."""
try:
return await call_tool(name, args)
return await call_tool(name, args, callables)
except Exception as e:
logger.warning("Tool %s failed: %s", name, e)
return f"Tool error: {e}"