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:
25
CLAUDE.md
25
CLAUDE.md
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
189
cortex/routers/tools_settings.py
Normal file
189
cortex/routers/tools_settings.py
Normal 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.",
|
||||
))
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
233
cortex/static/tools_settings.html
Normal file
233
cortex/static/tools_settings.html
Normal 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>
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user