feat: tool risk policy UI + wiring through all orchestrators

- New /settings/tools page: max_risk selector (low/medium/high) + per-tool
  override dropdowns (Default / Force include / Force exclude) for all 58 tools
  grouped by category with color-coded risk badges; JS updates Auto status live
- get_tools_for_role() + get_openai_tools_for_role() now accept max_risk,
  whitelist, blacklist; _apply_risk_policy() handles the filtering logic
- get_risk_policy() helper in auth_utils reads from tool_policy.json
- Risk policy wired through orchestrator.py, openai_orchestrator.py,
  orchestrator_engine.py, nextcloud_talk.py, homeassistant.py
- Tools nav link added to settings.html and notifications.html
- CLAUDE.md and ARCH__SYSTEM.md updated: tool count 50→58, risk system docs,
  tool access control three-layer model documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-11 22:45:04 -04:00
parent c9c1ca7de6
commit 69ec2f667d
15 changed files with 584 additions and 27 deletions

View File

@@ -205,7 +205,13 @@ Cortex is a no-black-box system. Docs must match reality — at all times.
1. Implement the tool function in `cortex/tools/<domain>.py`
- Must be `async def`; use `asyncio.to_thread` for blocking calls
- Return a plain string result
2. Add a `FunctionDeclaration` and register it in `cortex/tools/__init__.py`
2. Add a `FunctionDeclaration` and register it in `cortex/tools/__init__.py`:
- Import the callable
- Add to `TOOL_CATEGORIES` (pick an existing category or create one)
- Add to `_CALLABLES`
- Add a `TOOL_RISK` rating (low/medium/high)
- Add to `TOOL_ROLES` if admin-only; add to `CONFIRM_REQUIRED` if destructive
- Add module to `_ALL_DECLARATIONS`
3. Syntax check: `python3 -m py_compile cortex/tools/<domain>.py`
4. Restart Cortex
@@ -269,14 +275,21 @@ Cortex is running and stable. All channels are live:
Active users: scott (inara), holly (tina), brian (wintermute)
**50 orchestrator tools:** web_search, http_fetch, web_read, http_post,
file_read/list/write/session_read/session_search, shell_exec, claude_allow_dir,
**58 orchestrator tools** across 15 domain modules:
web_search/http_fetch/web_read/http_post,
project_file_read/list + file_stat/grep/syntax_check (project-scoped),
file_read/list/write/session_read/session_search (system-scoped, admin),
shell_exec/claude_allow_dir,
cortex_restart/logs/status/update,
task_list/create/update/complete, cron_list/add/remove/toggle,
reminders_add/list/remove/clear, scratch_read/write/append/clear,
web_push, email_send, nc_talk_send, nc_talk_history,
ae_journal_list/search/entries_list/entry_read/entry_create/entry_update/entry_disable/entry_append/entry_prepend,
ae_task_list, agent_notes_read/write/append/clear, spawn_agent.
web_push/email_send/nc_talk_send/nc_talk_history,
ae_journal_list/search/entries_list/entry_read/create/update/disable/append/prepend,
ae_task_list, agent_notes_read/write/append/clear, spawn_agent,
ha_get_state/ha_get_states/ha_call_service.
Each tool has a `TOOL_RISK` rating (low/medium/high). Configure access at `/settings/tools`
(max_risk threshold + per-tool whitelist/blacklist). Risk policy stored in `home/{user}/tool_policy.json`.
See `documentation/TODO__Agents.md` for the active task list.
See `documentation/ROADMAP.md` for phases and what's next.

View File

@@ -229,9 +229,14 @@ def get_user_channels(username: str) -> dict:
def get_tool_policy(username: str) -> dict:
"""Return the parsed tool_policy.json for a user.
Keys:
Confirmation-gate keys (existing):
allow — tools in CONFIRM_REQUIRED that this user has pre-approved (skip gate)
deny — tools always blocked for this user regardless of global CONFIRM_REQUIRED
Risk-policy keys (new):
max_risk — auto-include all tools at/below this level ("low"|"medium"|"high")
whitelist — force-include specific tools above max_risk
blacklist — force-exclude specific tools regardless of max_risk
"""
path = settings.home_root() / username / "tool_policy.json"
try:
@@ -240,6 +245,16 @@ def get_tool_policy(username: str) -> dict:
return {}
def get_risk_policy(username: str) -> tuple[str | None, list[str], list[str]]:
"""Return (max_risk, whitelist, blacklist) from the user's tool policy."""
policy = get_tool_policy(username)
return (
policy.get("max_risk") or None,
policy.get("whitelist") or [],
policy.get("blacklist") or [],
)
def save_tool_policy(username: str, data: dict) -> None:
path = settings.home_root() / username / "tool_policy.json"
path.write_text(json.dumps(data, indent=2) + "\n")

View File

@@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(messag
from config import settings
from auth_middleware import SessionAuthMiddleware
from routers import chat, google_chat, nextcloud_talk, homeassistant, files, distill, auth, orchestrator
from routers import ui, onboarding, settings, help, auth_google, local_llm, push, audit, usage
from routers import ui, onboarding, settings, tools_settings, help, auth_google, local_llm, push, audit, usage
@asynccontextmanager
@@ -51,6 +51,7 @@ app.include_router(onboarding.router)
# Account settings
app.include_router(settings.router)
app.include_router(tools_settings.router)
app.include_router(local_llm.router)
# Help page

View File

@@ -49,6 +49,9 @@ async def run(
tool_list: list[str] | None = None,
confirm_allow: set[str] | None = None,
confirm_deny: set[str] | None = None,
max_risk: str | None = None,
risk_whitelist: list[str] | None = None,
risk_blacklist: list[str] | None = None,
) -> OrchestratorResult:
"""
Run a tool-enabled task using an OpenAI-compatible API.
@@ -73,7 +76,10 @@ async def run(
_confirm_deny = frozenset(confirm_deny or ())
effective_confirm = (CONFIRM_REQUIRED - set(_confirm_allow)) | set(_confirm_deny)
client, model_name, active_tools = _build_client(model_cfg, user_role, tool_list)
client, model_name, active_tools = _build_client(
model_cfg, user_role, tool_list,
max_risk=max_risk, risk_whitelist=risk_whitelist, risk_blacklist=risk_blacklist,
)
tool_audit.set_context("openai", model_cfg.get("label") or model_name)
sys_content = (system_prompt or "") + _TOOL_INSTRUCTION
@@ -420,6 +426,9 @@ def _build_client(
model_cfg: dict | None,
user_role: str = "user",
tool_list: list[str] | None = None,
max_risk: str | None = None,
risk_whitelist: list[str] | None = None,
risk_blacklist: list[str] | None = None,
) -> tuple:
"""Build AsyncOpenAI client and return (client, model_name, active_tools)."""
if not model_cfg:
@@ -439,7 +448,10 @@ def _build_client(
if model_cfg.get("tools") is False:
active_tools = []
else:
active_tools = get_openai_tools_for_role(user_role, tool_list)
active_tools = get_openai_tools_for_role(
user_role, tool_list,
max_risk=max_risk, whitelist=risk_whitelist, blacklist=risk_blacklist,
)
return client, model_name, active_tools
@@ -448,9 +460,15 @@ async def _execute_tool(
arguments_json: str,
user_role: str = "user",
tool_list: list[str] | None = None,
max_risk: str | None = None,
risk_whitelist: list[str] | None = None,
risk_blacklist: list[str] | None = None,
) -> str:
"""Parse tool arguments and execute with role-filtered callables."""
_, callables = get_tools_for_role(user_role, tool_list)
_, callables = get_tools_for_role(
user_role, tool_list,
max_risk=max_risk, whitelist=risk_whitelist, blacklist=risk_blacklist,
)
try:
args = json.loads(arguments_json)
except json.JSONDecodeError:

View File

@@ -117,6 +117,9 @@ async def run(
confirm_allow: set[str] | None = None,
confirm_deny: set[str] | None = None,
max_rounds: int | None = None,
max_risk: str | None = None,
risk_whitelist: list[str] | None = None,
risk_blacklist: list[str] | None = None,
) -> OrchestratorResult:
"""
Run the full orchestration loop for a task.
@@ -154,7 +157,10 @@ async def run(
contents: list[types.Content] = [
types.Content(role="user", parts=[types.Part(text=task_with_context)])
]
tool_declarations, tool_callables = get_tools_for_role(user_role, tool_list)
tool_declarations, tool_callables = get_tools_for_role(
user_role, tool_list, max_risk=max_risk,
whitelist=risk_whitelist, blacklist=risk_blacklist,
)
tool_call_log: list[dict] = []
gemini_summary, checkpoint = await _run_from_contents(
@@ -203,7 +209,12 @@ async def resume(checkpoint: OrchestrateCheckpoint, confirmed: bool) -> Orchestr
"""Continue a job that was paused at a confirmation gate."""
api_key = checkpoint.gemini_api_key or settings.gemini_api_key
client = genai.Client(api_key=api_key)
tool_declarations, tool_callables = get_tools_for_role(checkpoint.user_role, checkpoint.tool_list)
tool_declarations, tool_callables = get_tools_for_role(
checkpoint.user_role, checkpoint.tool_list,
max_risk=getattr(checkpoint, "max_risk", None),
whitelist=getattr(checkpoint, "risk_whitelist", None),
blacklist=getattr(checkpoint, "risk_blacklist", None),
)
effective_confirm = (CONFIRM_REQUIRED - set(checkpoint.confirm_allow)) | set(checkpoint.confirm_deny)

View File

@@ -40,7 +40,7 @@ import logging
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response
from auth_utils import get_user_channels, get_user_gemini_key, get_user_role, get_tool_policy
from auth_utils import get_user_channels, get_user_gemini_key, get_user_role, get_tool_policy, get_risk_policy
from context_loader import load_context
from llm_client import complete
from notification import notify
@@ -99,6 +99,7 @@ async def _process_event(username: str, body: dict, cfg: dict) -> None:
policy = get_tool_policy(username)
c_allow = set(policy.get("allow", []))
c_deny = set(policy.get("deny", []))
max_risk, risk_wl, risk_bl = get_risk_policy(username)
if orch_model and orch_model.get("type") == "local_openai":
result = await openai_orchestrator.run(
@@ -110,6 +111,9 @@ async def _process_event(username: str, body: dict, cfg: dict) -> None:
tool_list=tool_list,
confirm_allow=c_allow,
confirm_deny=c_deny,
max_risk=max_risk,
risk_whitelist=risk_wl,
risk_blacklist=risk_bl,
)
else:
gemini_key = (
@@ -128,6 +132,9 @@ async def _process_event(username: str, body: dict, cfg: dict) -> None:
tool_list=tool_list,
confirm_allow=c_allow,
confirm_deny=c_deny,
max_risk=max_risk,
risk_whitelist=risk_wl,
risk_blacklist=risk_bl,
)
response_text = result.response
backend = result.backend

View File

@@ -6,7 +6,7 @@ import logging
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response
from auth_utils import get_user_channels, get_user_gemini_key, get_user_role, get_tool_policy
from auth_utils import get_user_channels, get_user_gemini_key, get_user_role, get_tool_policy, get_risk_policy
from context_loader import load_context
from llm_client import complete
from notification import _send_nct_message
@@ -95,6 +95,7 @@ async def _process_message(
policy = get_tool_policy(username)
c_allow = set(policy.get("allow", []))
c_deny = set(policy.get("deny", []))
max_risk, risk_wl, risk_bl = get_risk_policy(username)
if orch_model and orch_model.get("type") == "local_openai":
result = await openai_orchestrator.run(
@@ -106,6 +107,9 @@ async def _process_message(
tool_list=tool_list,
confirm_allow=c_allow,
confirm_deny=c_deny,
max_risk=max_risk,
risk_whitelist=risk_wl,
risk_blacklist=risk_bl,
)
else:
gemini_key = (
@@ -124,6 +128,9 @@ async def _process_message(
tool_list=tool_list,
confirm_allow=c_allow,
confirm_deny=c_deny,
max_risk=max_risk,
risk_whitelist=risk_wl,
risk_blacklist=risk_bl,
)
response_text = result.response

View File

@@ -19,7 +19,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from auth_utils import get_user_gemini_key, get_user_role, get_tool_policy
from auth_utils import get_user_gemini_key, get_user_role, get_tool_policy, get_risk_policy
from config import settings
from context_loader import load_context
from persona import set_context, validate as validate_persona
@@ -224,6 +224,7 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
policy = get_tool_policy(user)
confirm_allow = set(policy.get("allow", []))
confirm_deny = set(policy.get("deny", []))
max_risk, risk_wl, risk_bl = get_risk_policy(user)
if orch_model and orch_model.get("type") == "local_openai":
result = await openai_orchestrator.run(
@@ -236,6 +237,9 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
tool_list=tool_list,
confirm_allow=confirm_allow,
confirm_deny=confirm_deny,
max_risk=max_risk,
risk_whitelist=risk_wl,
risk_blacklist=risk_bl,
)
else:
gemini_key = (
@@ -255,6 +259,9 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
confirm_allow=confirm_allow,
confirm_deny=confirm_deny,
max_rounds=orch_model.get("max_rounds") if orch_model else None,
max_risk=max_risk,
risk_whitelist=risk_wl,
risk_blacklist=risk_bl,
)
if result.checkpoint:

View File

@@ -0,0 +1,189 @@
"""
Tool settings router.
Routes:
GET /settings/tools → tool risk policy page
POST /settings/tools → save max_risk + per-tool overrides
"""
import html as _html
import json
import logging
from pathlib import Path
import jwt
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from auth_utils import COOKIE_NAME, decode_token, get_tool_policy, save_tool_policy
from persona import list_user_personas
from tools import TOOL_CATEGORIES, TOOL_RISK
logger = logging.getLogger(__name__)
router = APIRouter()
_STATIC = Path(__file__).parent.parent / "static"
_LAST_PERSONA_COOKIE = "cx_last_persona"
def _get_session_user(request: Request) -> str | None:
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
try:
return decode_token(token)
except jwt.InvalidTokenError:
return None
def _preferred_persona(request: Request, username: str) -> str:
names = list_user_personas(username)
if not names:
return ""
cookie_val = request.cookies.get(_LAST_PERSONA_COOKIE, "")
return cookie_val if cookie_val in names else (names[0] if names else "")
def _build_tool_table(policy: dict) -> str:
"""Generate the per-tool override table rows grouped by category."""
whitelist = set(policy.get("whitelist") or [])
blacklist = set(policy.get("blacklist") or [])
rows: list[str] = []
for category, tools in TOOL_CATEGORIES.items():
# Category header spanning all columns
escaped_cat = _html.escape(category)
rows.append(
f'<tr><td colspan="4" style="padding:0.75rem 0.9rem 0.3rem;'
f'font-size:0.72rem;font-weight:700;letter-spacing:0.07em;'
f'text-transform:uppercase;color:var(--pg-dimmer);'
f'border-bottom:1px solid var(--pg-border);">'
f'{escaped_cat}</td></tr>'
)
for tool in tools:
risk = TOOL_RISK.get(tool, "medium")
risk_cls = f"risk-{risk}"
risk_html = f'<span class="risk {risk_cls}">{_html.escape(risk)}</span>'
# Override select value
if tool in whitelist:
override_val = "whitelist"
elif tool in blacklist:
override_val = "blacklist"
else:
override_val = "default"
def _opt(val: str, label: str) -> str:
sel = 'selected' if override_val == val else ''
return f'<option value="{val}" {sel}>{label}</option>'
override_sel = (
f'<select name="override_{_html.escape(tool)}" '
f'class="override-sel" data-tool="{_html.escape(tool)}">'
+ _opt("default", "Default (auto)")
+ _opt("whitelist", "Force include")
+ _opt("blacklist", "Force exclude")
+ '</select>'
)
rows.append(
f'<tr data-tool-risk="{_html.escape(risk)}">'
f'<td class="tool-name">{_html.escape(tool)}</td>'
f'<td>{risk_html}</td>'
f'<td><span class="auto-pill"></span></td>'
f'<td>{override_sel}</td>'
f'</tr>'
)
table_body = "\n".join(rows)
return (
'<table class="tool-table">'
'<thead><tr>'
'<th>Tool</th><th>Risk</th><th>Auto status</th><th>Override</th>'
'</tr></thead>'
f'<tbody>{table_body}</tbody>'
'</table>'
)
def _tools_page(
username: str,
back_persona: str = "",
success: str = "",
error: str = "",
) -> str:
html = (_STATIC / "tools_settings.html").read_text()
policy = get_tool_policy(username)
max_risk = policy.get("max_risk") or ""
# Max risk select options
html = html.replace("{{ sel_none }}", "selected" if max_risk == "" else "")
html = html.replace("{{ sel_low }}", "selected" if max_risk == "low" else "")
html = html.replace("{{ sel_medium }}", "selected" if max_risk == "medium" else "")
html = html.replace("{{ sel_high }}", "selected" if max_risk == "high" else "")
html = html.replace("{{ tool_table_html }}", _build_tool_table(policy))
html = html.replace("{{ tool_risk_json }}", json.dumps(TOOL_RISK))
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
if success:
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
if error:
html = html.replace("<!-- ERROR -->", f'<p class="error">{error}</p>')
return html
@router.get("/settings/tools", include_in_schema=False)
async def tools_page(request: Request):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
back_persona = _preferred_persona(request, username)
return HTMLResponse(_tools_page(username, back_persona))
@router.post("/settings/tools", include_in_schema=False)
async def save_tools(request: Request):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
back_persona = _preferred_persona(request, username)
form = await request.form()
max_risk = (form.get("max_risk") or "").strip()
if max_risk not in ("", "low", "medium", "high"):
max_risk = ""
whitelist: list[str] = []
blacklist: list[str] = []
all_tools = [t for tools in TOOL_CATEGORIES.values() for t in tools]
for tool in all_tools:
val = (form.get(f"override_{tool}") or "").strip()
if val == "whitelist":
whitelist.append(tool)
elif val == "blacklist":
blacklist.append(tool)
# Merge into existing policy (preserve allow/deny confirmation-gate fields)
policy = get_tool_policy(username)
if max_risk:
policy["max_risk"] = max_risk
else:
policy.pop("max_risk", None)
policy["whitelist"] = whitelist
policy["blacklist"] = blacklist
save_tool_policy(username, policy)
logger.info(
"tool policy saved for %s: max_risk=%s whitelist=%d blacklist=%d",
username, max_risk or "none", len(whitelist), len(blacklist),
)
return HTMLResponse(_tools_page(
username, back_persona,
success=f"Tool policy saved — max risk: {max_risk or 'none'}, "
f"{len(whitelist)} whitelisted, {len(blacklist)} blacklisted.",
))

View File

@@ -210,6 +210,7 @@
<a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a>
<a href="/settings/notifications" class="nav-link active">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a>
<span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav>

View File

@@ -265,6 +265,7 @@
<a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link active">Settings</a>
<a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a>
<span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav>

View File

@@ -0,0 +1,233 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tool Settings — Cortex</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
color-scheme: light dark;
--pg-bg: #f8fafc; --pg-card: #ffffff; --pg-border: #e2e8f0;
--pg-text: #1e293b; --pg-muted: #64748b; --pg-dimmer: #94a3b8;
--pg-bright: #cbd5e1; --pg-nav-hover: rgba(255,255,255,0.05);
--pg-accent: #7c3aed;
}
@media (prefers-color-scheme: dark) {
:root {
--pg-bg: #0f0a1e; --pg-card: #1a1228; --pg-border: #2d2040;
--pg-text: #e2d9f3; --pg-muted: #9d8ec4; --pg-dimmer: #6b5d8a;
--pg-bright: #1a1228; --pg-nav-hover: rgba(0,0,0,0.05);
}
}
body { font-family: system-ui, sans-serif; background: var(--pg-bg); color: var(--pg-text); min-height: 100vh; }
.page-nav {
display: flex; align-items: center; gap: 0.25rem;
padding: 0.5rem 1rem; background: var(--pg-card);
border-bottom: 1px solid var(--pg-border); flex-wrap: wrap;
}
.nav-link {
padding: 0.35rem 0.7rem; border-radius: 0.375rem; font-size: 0.875rem;
color: var(--pg-muted); text-decoration: none; white-space: nowrap;
}
.nav-link:hover { color: var(--pg-text); background: var(--pg-nav-hover); }
.nav-link.active { color: #a78bfa; }
.nav-spacer { flex: 1; min-width: 0.5rem; }
.nav-link.nav-logout { color: var(--pg-dimmer); }
.nav-link.nav-logout:hover { color: var(--pg-muted); background: none; }
.page-wrap { max-width: 860px; margin: 0 auto; padding: 2rem 1rem 4rem; }
h1 { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.25rem; }
.page-lead { color: var(--pg-muted); font-size: 0.9rem; margin-bottom: 2rem; }
.success { color: #22c55e; font-size: 0.9rem; margin: 0.5rem 0; }
.error { color: #f87171; font-size: 0.9rem; margin: 0.5rem 0; }
/* ── Risk policy card ── */
.policy-card {
background: var(--pg-card); border: 1px solid var(--pg-border);
border-radius: 0.75rem; padding: 1.25rem 1.5rem; margin-bottom: 1.75rem;
}
.policy-card h2 { font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; }
.policy-row { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
.policy-label { font-size: 0.875rem; font-weight: 500; min-width: 6rem; }
.policy-note { font-size: 0.8rem; color: var(--pg-muted); margin-top: 0.35rem; line-height: 1.5; }
select, input[type="text"] {
background: var(--pg-bg); border: 1px solid var(--pg-border);
border-radius: 0.375rem; color: var(--pg-text);
padding: 0.4rem 0.65rem; font-size: 0.875rem;
}
select:focus, input:focus { outline: 2px solid var(--pg-accent); border-color: transparent; }
.save-btn {
background: var(--pg-accent); color: #fff; border: none;
border-radius: 0.5rem; padding: 0.5rem 1.4rem;
font-size: 0.875rem; font-weight: 600; cursor: pointer;
margin-top: 0.5rem;
}
.save-btn:hover { opacity: 0.88; }
/* ── Tool table ── */
.section-head {
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--pg-dimmer);
margin: 1.75rem 0 0.6rem;
}
.tool-table {
width: 100%; border-collapse: collapse;
background: var(--pg-card); border: 1px solid var(--pg-border);
border-radius: 0.75rem; overflow: hidden; margin-bottom: 0.5rem;
font-size: 0.85rem;
}
.tool-table th {
text-align: left; padding: 0.55rem 0.9rem;
border-bottom: 1px solid var(--pg-border);
color: var(--pg-muted); font-weight: 600; font-size: 0.78rem;
text-transform: uppercase; letter-spacing: 0.04em;
}
.tool-table td { padding: 0.5rem 0.9rem; border-bottom: 1px solid var(--pg-border); vertical-align: middle; }
.tool-table tr:last-child td { border-bottom: none; }
.tool-table tr:hover td { background: rgba(124,58,237,0.04); }
.tool-name { font-family: monospace; font-size: 0.82rem; }
/* Risk badges */
.risk { display: inline-block; font-size: 0.7rem; font-weight: 700;
padding: 0.15rem 0.45rem; border-radius: 9999px; letter-spacing: 0.04em; }
.risk-low { background: rgba(34,197,94,0.15); color: #16a34a; }
.risk-medium { background: rgba(234,179, 8,0.15); color: #ca8a04; }
.risk-high { background: rgba(239,68, 68,0.15); color: #dc2626; }
@media (prefers-color-scheme: dark) {
.risk-low { background: rgba(34,197,94,0.12); color: #4ade80; }
.risk-medium { background: rgba(234,179, 8,0.12); color: #fbbf24; }
.risk-high { background: rgba(239,68, 68,0.12); color: #f87171; }
}
/* Auto status pill */
.auto-pill {
display: inline-block; font-size: 0.68rem; font-weight: 600;
padding: 0.12rem 0.4rem; border-radius: 9999px;
}
.auto-on { background: rgba(124,58,237,0.12); color: #7c3aed; }
.auto-off { background: rgba(148,163,184,0.12); color: var(--pg-dimmer); }
@media (prefers-color-scheme: dark) {
.auto-on { color: #a78bfa; }
}
/* Override select */
.override-sel {
font-size: 0.78rem; padding: 0.25rem 0.5rem;
border-radius: 0.3rem; min-width: 7rem;
}
.override-sel.forced-on { border-color: #7c3aed; color: #7c3aed; }
.override-sel.forced-off { border-color: #dc2626; color: #dc2626; }
/* Legend */
.legend { display: flex; gap: 1.25rem; flex-wrap: wrap; margin-bottom: 1.25rem; font-size: 0.8rem; color: var(--pg-muted); }
.legend-dot { display: inline-block; width: 0.55rem; height: 0.55rem; border-radius: 50%; margin-right: 0.3rem; }
.legend-dot.on { background: #7c3aed; }
.legend-dot.off { background: var(--pg-dimmer); }
</style>
</head>
<body>
<nav class="page-nav">
<a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a>
<a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link active">Tools</a>
<span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav>
<div class="page-wrap">
<h1>Tool Settings</h1>
<p class="page-lead">
Control which orchestrator tools are available. The risk level sets an automatic threshold;
whitelist and blacklist let you fine-tune individual tools beyond that.
</p>
<!-- SUCCESS -->
<!-- ERROR -->
<form method="POST" action="/settings/tools" id="tools-form">
<!-- ── Risk policy ── -->
<div class="policy-card">
<h2>Risk Policy</h2>
<div class="policy-row">
<span class="policy-label">Max risk level</span>
<select name="max_risk" id="max-risk-sel">
<option value="" {{ sel_none }}>No filter — use all role-permitted tools</option>
<option value="low" {{ sel_low }}>Low — read-only and sandboxed tools only</option>
<option value="medium" {{ sel_medium }}>Medium — low + medium risk (recommended)</option>
<option value="high" {{ sel_high }}>High — all tools including destructive ones</option>
</select>
</div>
<p class="policy-note">
<strong>Low</strong> tools are read-only and sandboxed (web search, project file reads, HA status checks).<br>
<strong>Medium</strong> tools write to local data or send notifications to you (cron jobs, scratch, task management).<br>
<strong>High</strong> tools affect external systems or the host (shell exec, email, device control, service restart).
</p>
<p class="policy-note" style="margin-top:0.75rem;">
The <em>Auto</em> column below shows each tool's status at your current max risk level.
Use the override column to force-include or force-exclude individual tools.
</p>
</div>
<!-- ── Legend ── -->
<div class="legend">
<span><span class="legend-dot on"></span>Auto-included by risk level</span>
<span><span class="legend-dot off"></span>Auto-excluded by risk level</span>
</div>
<!-- ── Tool table ── -->
{{ tool_table_html }}
<div style="margin-top:1.5rem;">
<button type="submit" class="save-btn">Save tool settings</button>
</div>
</form>
</div>
<script>
const riskRank = { "": 99, "low": 0, "medium": 1, "high": 2 };
const toolRisk = {{ tool_risk_json }};
const sel = document.getElementById('max-risk-sel');
function updateAutoPills() {
const maxRank = riskRank[sel.value] ?? 99;
document.querySelectorAll('[data-tool-risk]').forEach(row => {
const risk = row.dataset.toolRisk;
const pill = row.querySelector('.auto-pill');
const isAuto = riskRank[risk] <= maxRank;
pill.textContent = isAuto ? 'auto ✓' : 'excluded';
pill.className = 'auto-pill ' + (isAuto ? 'auto-on' : 'auto-off');
});
}
sel.addEventListener('change', updateAutoPills);
updateAutoPills();
// Color the override selects
document.querySelectorAll('.override-sel').forEach(s => {
function refresh() {
s.className = 'override-sel';
if (s.value === 'whitelist') s.classList.add('forced-on');
if (s.value === 'blacklist') s.classList.add('forced-off');
}
s.addEventListener('change', refresh);
refresh();
});
</script>
</body>
</html>

View File

@@ -444,18 +444,50 @@ OPENAI_TOOL_SCHEMAS: list[dict] = _build_openai_tools()
# ── Role-filtered tool access ─────────────────────────────────────────────────
def _apply_risk_policy(
allowed: set[str],
max_risk: str | None,
whitelist: list[str] | None,
blacklist: list[str] | None,
) -> set[str]:
"""Apply risk-level filtering on top of an already role-gated allowed set.
Filtering order (each step can only restrict or restore within what the
role already permits — risk policy can never elevate above role):
1. max_risk auto-include: keep tools whose risk ≤ max_risk
2. whitelist union: force-add specific tools (still role-gated)
3. blacklist subtract: force-remove specific tools
When max_risk is None, all role-allowed tools remain (no risk filter).
"""
if max_risk is not None:
max_rank = _RISK_RANK.get(max_risk, 2)
auto = {n for n in allowed if _RISK_RANK.get(TOOL_RISK.get(n, "medium"), 1) <= max_rank}
extra = {n for n in (whitelist or []) if n in allowed}
allowed = (auto | extra)
if blacklist:
allowed -= set(blacklist)
return allowed
def get_tools_for_role(
role: str,
tool_list: list[str] | None = None,
max_risk: str | None = None,
whitelist: list[str] | None = None,
blacklist: list[str] | None = None,
) -> tuple[list, dict]:
"""Return (gemini_tool_declarations, callables_dict) filtered to tools the role can use.
"""Return (gemini_tool_declarations, callables_dict) filtered to what the role can use.
role — user access level ("user" | "admin"); gates admin-only tools
tool_list — optional explicit allow-list from role config (e.g. coder role);
intersected with the access-level filter so it can only restrict,
never elevate privileges
tool_list — optional model-level allow-list; intersected so it can only restrict
max_risk — auto-include tools at/below this risk level ("low"|"medium"|"high")
whitelist — force-include specific tools above max_risk (still role-gated)
blacklist — force-exclude specific tools regardless of max_risk
"""
allowed = {name for name in _CALLABLES if _role_allowed(name, role)}
allowed = _apply_risk_policy(allowed, max_risk, whitelist, blacklist)
if tool_list is not None:
allowed &= set(tool_list)
decls = [d for d in _ALL_DECLARATIONS if d.name in allowed]
@@ -466,13 +498,20 @@ def get_tools_for_role(
def get_openai_tools_for_role(
role: str,
tool_list: list[str] | None = None,
max_risk: str | None = None,
whitelist: list[str] | None = None,
blacklist: list[str] | None = None,
) -> list[dict]:
"""Return OpenAI tool schemas filtered to tools the role can use.
"""Return OpenAI tool schemas filtered to what the role can use.
role — user access level ("user" | "admin")
tool_list — optional explicit allow-list from role config
tool_list — optional model-level allow-list
max_risk — auto-include tools at/below this risk level
whitelist — force-include specific tools above max_risk
blacklist — force-exclude specific tools
"""
allowed = {name for name in _CALLABLES if _role_allowed(name, role)}
allowed = _apply_risk_policy(allowed, max_risk, whitelist, blacklist)
if tool_list is not None:
allowed &= set(tool_list)
return [t for t in OPENAI_TOOL_SCHEMAS if t["function"]["name"] in allowed]

View File

@@ -71,9 +71,9 @@ Details: [`ARCH__BACKENDS.md`](ARCH__BACKENDS.md) | [`ARCH__PERSONA.md`](ARCH__P
| `event_bus.py` | Internal SSE pub/sub (NC Talk → browser mirror) |
| `email_utils.py` | SMTP invite emails |
| `persona_template.py` | Bootstrap a new persona directory from templates |
| `routers/` | One file per endpoint group — `chat`, `orchestrator`, `auth`, `files`, `ui`, `settings`, `local_llm`, `distill`, `audit`, `usage`, `push`, `help`, `onboarding`, `auth_google`, `nextcloud_talk`, `google_chat` |
| `tools/` | Orchestrator tool implementations — `web` (search/fetch/web_read), `files` (file_read/write/session_read/search), `tasks`, `scratch`, `reminders`, `cron`, `system`, `notify`, `ae_journals`, `ae_tasks`, `agent_notes`, `agents` (spawn_agent) |
| `static/` | Web UI — `index.html`, `app.js`, `style.css`, `login.html`, `setup.html`, `HELP.md`, `local_llm.html`, `settings.html` |
| `routers/` | One file per endpoint group — `chat`, `orchestrator`, `auth`, `files`, `ui`, `settings`, `tools_settings`, `local_llm`, `distill`, `audit`, `usage`, `push`, `help`, `onboarding`, `auth_google`, `nextcloud_talk`, `google_chat`, `homeassistant` |
| `tools/` | 58 orchestrator tools in 15 domain modules — `web`, `files` (project + system scope), `tasks`, `scratch`, `reminders`, `cron`, `system`, `notify`, `ae_knowledge`, `ae_tasks`, `agent_notes`, `agents`, `homeassistant`. Registry and access control in `tools/__init__.py`. |
| `static/` | Web UI — `index.html`, `app.js`, `style.css`, `login.html`, `setup.html`, `HELP.md`, `local_llm.html`, `settings.html`, `notifications.html`, `tools_settings.html` |
| `tests/` | pytest suite |
---
@@ -94,6 +94,13 @@ Details: [`ARCH__BACKENDS.md`](ARCH__BACKENDS.md) | [`ARCH__PERSONA.md`](ARCH__P
**No single point of coupling** — tools live in `cortex/tools/`, separate from `ae_*` MCP tools. Channels live in `cortex/routers/`, each self-contained. Adding a channel or tool doesn't touch other subsystems.
**Tool access control (three layers):**
1. **Role gate** (`TOOL_ROLES` in `tools/__init__.py`) — admin-only tools require `admin` role in `auth.json`.
2. **Risk policy** (`home/{user}/tool_policy.json`) — `max_risk` auto-includes all tools at or below a level (low/medium/high); `whitelist`/`blacklist` override individual tools. Configurable at `/settings/tools`.
3. **Model-level tool list** — per-role `tools` field in `local_llm.json`; can only restrict further, never elevate.
All 58 tools carry a `TOOL_RISK` rating (36 low / 12 medium / 10 high) used for auto-filtering. `CONFIRM_REQUIRED` is a separate static set of tools that trigger a user confirmation prompt before executing, independent of risk level.
**Agent private notes**`AGENT_NOTES.md` per persona, writable only by the orchestrator via `agent_notes_*` tools. Never loaded into user-facing context. Three rolling backups (`bak1``bak3`) are visible read-only in the Files panel. Declared in `tools/agent_notes.py`; usage guidance in `PROTOCOLS.md`.
**No black boxes** — Every component, flow, and design decision is documented. Documentation is updated before implementation of significant changes and verified after. HELP.md is the user-facing contract; ARCH__*.md files are the developer contract; PROTOCOLS.md is the agent contract. If any of these drift from reality, that is a bug.

View File

@@ -480,3 +480,11 @@ other based on resources and specialisation. No central coordinator required.
- FastAPI service with streaming SSE response
- Claude CLI and Gemini CLI subprocess backends
- Session context management (rolling window, `MAX_HISTORY_MESSAGES`)
### [Tools] Orchestrator tool expansions — Round 3
- [ ] **`spawn_agent` tool restrictions** — add `allow_tools` and `deny_tools` optional params to `spawn_agent` so the spawning agent can restrict which tools a sub-agent has access to, independent of role config.
- Role config remains the authoritative max; spawner provides per-call restriction.
- Design spec: `ARCH__FUTURE.md` §12
- Files to touch: `cortex/tools/agents.py` (filtering logic), Gemini `FunctionDeclaration` (new params)