- 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>
127 lines
4.2 KiB
Python
127 lines
4.2 KiB
Python
"""
|
|
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__)
|
|
|
|
ALLOW_SCRIPT = "/home/scott/.local/bin/claude-allow-dir"
|
|
|
|
|
|
async def claude_allow_dir(path: str, mode: str = "rw") -> str:
|
|
"""Add Read/Edit allow rules to ~/.claude/settings.json for a directory.
|
|
|
|
Calls the claude-allow-dir script, which edits settings.json directly.
|
|
Changes take effect in the next Claude Code session (or after /hooks reload).
|
|
"""
|
|
if mode not in ("r", "w", "rw"):
|
|
return f"Error: mode must be r, w, or rw (got '{mode}')"
|
|
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"python3", ALLOW_SCRIPT, path, mode,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
|
output = stdout.decode().strip()
|
|
err = stderr.decode().strip()
|
|
|
|
if proc.returncode != 0:
|
|
logger.warning("claude-allow-dir failed (rc=%d): %s", proc.returncode, err)
|
|
return f"Failed (exit {proc.returncode}): {err or output}"
|
|
|
|
return output or "Done."
|
|
|
|
except asyncio.TimeoutError:
|
|
return "Error: script timed out"
|
|
except Exception as e:
|
|
logger.error("claude_allow_dir error: %s", e)
|
|
return f"Error: {e}"
|
|
|
|
|
|
async def shell_exec(command: str, working_dir: str | None = None, timeout: int = 30) -> str:
|
|
"""Execute a shell command on the Cortex host and return combined stdout/stderr."""
|
|
timeout = min(max(timeout, 1), 120)
|
|
|
|
cwd = None
|
|
if working_dir:
|
|
cwd = os.path.expanduser(working_dir)
|
|
if not os.path.isdir(cwd):
|
|
return f"Error: working_dir '{working_dir}' does not exist or is not a directory"
|
|
|
|
try:
|
|
proc = await asyncio.create_subprocess_shell(
|
|
command,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
cwd=cwd,
|
|
)
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
|
|
out = stdout.decode(errors="replace").strip()
|
|
err = stderr.decode(errors="replace").strip()
|
|
|
|
parts = []
|
|
if out:
|
|
parts.append(out)
|
|
if err:
|
|
parts.append(f"[stderr]\n{err}")
|
|
combined = "\n".join(parts) if parts else "(no output)"
|
|
|
|
if proc.returncode != 0:
|
|
return f"Exit {proc.returncode}:\n{combined}"
|
|
return combined
|
|
|
|
except asyncio.TimeoutError:
|
|
return f"Error: command timed out after {timeout}s"
|
|
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}"
|