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:
@@ -25,7 +25,7 @@ from openai import AsyncOpenAI
|
||||
|
||||
from config import settings
|
||||
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__)
|
||||
|
||||
@@ -44,6 +44,7 @@ async def run(
|
||||
session_messages: list[dict] | None = None,
|
||||
model_cfg: dict | None = None,
|
||||
respond_with_final: bool = True,
|
||||
user_role: str = "user",
|
||||
) -> OrchestratorResult:
|
||||
"""
|
||||
Run a tool-enabled task using an OpenAI-compatible API.
|
||||
@@ -93,6 +94,9 @@ async def run(
|
||||
)
|
||||
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] = []
|
||||
final_response = ""
|
||||
|
||||
@@ -103,7 +107,7 @@ async def run(
|
||||
response = await client.chat.completions.create(
|
||||
model=model_name,
|
||||
messages=messages,
|
||||
tools=OPENAI_TOOL_SCHEMAS,
|
||||
tools=active_tools,
|
||||
tool_choice="auto",
|
||||
)
|
||||
|
||||
@@ -130,39 +134,54 @@ async def run(
|
||||
messages.append(assistant_msg)
|
||||
|
||||
if choice.finish_reason == "tool_calls" and msg.tool_calls:
|
||||
# Execute all tool calls in parallel, then feed results back
|
||||
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))
|
||||
confirm_requested = False
|
||||
|
||||
for tc in msg.tool_calls:
|
||||
name = tc.function.name
|
||||
try:
|
||||
args_parsed = json.loads(tc.function.arguments)
|
||||
except json.JSONDecodeError:
|
||||
args_parsed = {"raw": tc.function.arguments}
|
||||
|
||||
tool_call_log.append({
|
||||
"tool": tc.function.name,
|
||||
"args": args_parsed,
|
||||
"result": result_str,
|
||||
})
|
||||
if name in CONFIRM_REQUIRED:
|
||||
args_str = json.dumps(args_parsed, indent=2) if args_parsed 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, 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({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"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:
|
||||
# finish_reason == "stop" (or no tool_calls) — model is done
|
||||
final_response = msg.content or ""
|
||||
@@ -194,14 +213,16 @@ async def run(
|
||||
)
|
||||
|
||||
|
||||
async def _execute_tool(name: str, arguments_json: str) -> str:
|
||||
"""Parse tool arguments and execute, returning a string result."""
|
||||
async def _execute_tool(name: str, arguments_json: str, user_role: str = "user") -> str:
|
||||
"""Parse tool arguments and execute with role-filtered callables."""
|
||||
from tools import get_tools_for_role
|
||||
_, callables = get_tools_for_role(user_role)
|
||||
try:
|
||||
args = json.loads(arguments_json)
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
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}"
|
||||
|
||||
Reference in New Issue
Block a user