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`
|
1. Implement the tool function in `cortex/tools/<domain>.py`
|
||||||
- Must be `async def`; use `asyncio.to_thread` for blocking calls
|
- Must be `async def`; use `asyncio.to_thread` for blocking calls
|
||||||
- Return a plain string result
|
- 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`
|
3. Syntax check: `python3 -m py_compile cortex/tools/<domain>.py`
|
||||||
4. Restart Cortex
|
4. Restart Cortex
|
||||||
|
|
||||||
@@ -269,14 +275,21 @@ Cortex is running and stable. All channels are live:
|
|||||||
|
|
||||||
Active users: scott (inara), holly (tina), brian (wintermute)
|
Active users: scott (inara), holly (tina), brian (wintermute)
|
||||||
|
|
||||||
**50 orchestrator tools:** web_search, http_fetch, web_read, http_post,
|
**58 orchestrator tools** across 15 domain modules:
|
||||||
file_read/list/write/session_read/session_search, shell_exec, claude_allow_dir,
|
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,
|
cortex_restart/logs/status/update,
|
||||||
task_list/create/update/complete, cron_list/add/remove/toggle,
|
task_list/create/update/complete, cron_list/add/remove/toggle,
|
||||||
reminders_add/list/remove/clear, scratch_read/write/append/clear,
|
reminders_add/list/remove/clear, scratch_read/write/append/clear,
|
||||||
web_push, email_send, nc_talk_send, nc_talk_history,
|
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_journal_list/search/entries_list/entry_read/create/update/disable/append/prepend,
|
||||||
ae_task_list, agent_notes_read/write/append/clear, spawn_agent.
|
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/TODO__Agents.md` for the active task list.
|
||||||
See `documentation/ROADMAP.md` for phases and what's next.
|
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:
|
def get_tool_policy(username: str) -> dict:
|
||||||
"""Return the parsed tool_policy.json for a user.
|
"""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)
|
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
|
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"
|
path = settings.home_root() / username / "tool_policy.json"
|
||||||
try:
|
try:
|
||||||
@@ -240,6 +245,16 @@ def get_tool_policy(username: str) -> dict:
|
|||||||
return {}
|
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:
|
def save_tool_policy(username: str, data: dict) -> None:
|
||||||
path = settings.home_root() / username / "tool_policy.json"
|
path = settings.home_root() / username / "tool_policy.json"
|
||||||
path.write_text(json.dumps(data, indent=2) + "\n")
|
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 config import settings
|
||||||
from auth_middleware import SessionAuthMiddleware
|
from auth_middleware import SessionAuthMiddleware
|
||||||
from routers import chat, google_chat, nextcloud_talk, homeassistant, files, distill, auth, orchestrator
|
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
|
@asynccontextmanager
|
||||||
@@ -51,6 +51,7 @@ app.include_router(onboarding.router)
|
|||||||
|
|
||||||
# Account settings
|
# Account settings
|
||||||
app.include_router(settings.router)
|
app.include_router(settings.router)
|
||||||
|
app.include_router(tools_settings.router)
|
||||||
app.include_router(local_llm.router)
|
app.include_router(local_llm.router)
|
||||||
|
|
||||||
# Help page
|
# Help page
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ async def run(
|
|||||||
tool_list: list[str] | None = None,
|
tool_list: list[str] | None = None,
|
||||||
confirm_allow: set[str] | None = None,
|
confirm_allow: set[str] | None = None,
|
||||||
confirm_deny: 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:
|
) -> OrchestratorResult:
|
||||||
"""
|
"""
|
||||||
Run a tool-enabled task using an OpenAI-compatible API.
|
Run a tool-enabled task using an OpenAI-compatible API.
|
||||||
@@ -73,7 +76,10 @@ async def run(
|
|||||||
_confirm_deny = frozenset(confirm_deny or ())
|
_confirm_deny = frozenset(confirm_deny or ())
|
||||||
effective_confirm = (CONFIRM_REQUIRED - set(_confirm_allow)) | set(_confirm_deny)
|
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)
|
tool_audit.set_context("openai", model_cfg.get("label") or model_name)
|
||||||
|
|
||||||
sys_content = (system_prompt or "") + _TOOL_INSTRUCTION
|
sys_content = (system_prompt or "") + _TOOL_INSTRUCTION
|
||||||
@@ -420,6 +426,9 @@ def _build_client(
|
|||||||
model_cfg: dict | None,
|
model_cfg: dict | None,
|
||||||
user_role: str = "user",
|
user_role: str = "user",
|
||||||
tool_list: list[str] | None = None,
|
tool_list: list[str] | None = None,
|
||||||
|
max_risk: str | None = None,
|
||||||
|
risk_whitelist: list[str] | None = None,
|
||||||
|
risk_blacklist: list[str] | None = None,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""Build AsyncOpenAI client and return (client, model_name, active_tools)."""
|
"""Build AsyncOpenAI client and return (client, model_name, active_tools)."""
|
||||||
if not model_cfg:
|
if not model_cfg:
|
||||||
@@ -439,7 +448,10 @@ def _build_client(
|
|||||||
if model_cfg.get("tools") is False:
|
if model_cfg.get("tools") is False:
|
||||||
active_tools = []
|
active_tools = []
|
||||||
else:
|
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
|
return client, model_name, active_tools
|
||||||
|
|
||||||
|
|
||||||
@@ -448,9 +460,15 @@ async def _execute_tool(
|
|||||||
arguments_json: str,
|
arguments_json: str,
|
||||||
user_role: str = "user",
|
user_role: str = "user",
|
||||||
tool_list: list[str] | None = None,
|
tool_list: list[str] | None = None,
|
||||||
|
max_risk: str | None = None,
|
||||||
|
risk_whitelist: list[str] | None = None,
|
||||||
|
risk_blacklist: list[str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Parse tool arguments and execute with role-filtered callables."""
|
"""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:
|
try:
|
||||||
args = json.loads(arguments_json)
|
args = json.loads(arguments_json)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ async def run(
|
|||||||
confirm_allow: set[str] | None = None,
|
confirm_allow: set[str] | None = None,
|
||||||
confirm_deny: set[str] | None = None,
|
confirm_deny: set[str] | None = None,
|
||||||
max_rounds: int | 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:
|
) -> OrchestratorResult:
|
||||||
"""
|
"""
|
||||||
Run the full orchestration loop for a task.
|
Run the full orchestration loop for a task.
|
||||||
@@ -154,7 +157,10 @@ async def run(
|
|||||||
contents: list[types.Content] = [
|
contents: list[types.Content] = [
|
||||||
types.Content(role="user", parts=[types.Part(text=task_with_context)])
|
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] = []
|
tool_call_log: list[dict] = []
|
||||||
|
|
||||||
gemini_summary, checkpoint = await _run_from_contents(
|
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."""
|
"""Continue a job that was paused at a confirmation gate."""
|
||||||
api_key = checkpoint.gemini_api_key or settings.gemini_api_key
|
api_key = checkpoint.gemini_api_key or settings.gemini_api_key
|
||||||
client = genai.Client(api_key=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)
|
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 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 context_loader import load_context
|
||||||
from llm_client import complete
|
from llm_client import complete
|
||||||
from notification import notify
|
from notification import notify
|
||||||
@@ -99,6 +99,7 @@ async def _process_event(username: str, body: dict, cfg: dict) -> None:
|
|||||||
policy = get_tool_policy(username)
|
policy = get_tool_policy(username)
|
||||||
c_allow = set(policy.get("allow", []))
|
c_allow = set(policy.get("allow", []))
|
||||||
c_deny = set(policy.get("deny", []))
|
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":
|
if orch_model and orch_model.get("type") == "local_openai":
|
||||||
result = await openai_orchestrator.run(
|
result = await openai_orchestrator.run(
|
||||||
@@ -110,6 +111,9 @@ async def _process_event(username: str, body: dict, cfg: dict) -> None:
|
|||||||
tool_list=tool_list,
|
tool_list=tool_list,
|
||||||
confirm_allow=c_allow,
|
confirm_allow=c_allow,
|
||||||
confirm_deny=c_deny,
|
confirm_deny=c_deny,
|
||||||
|
max_risk=max_risk,
|
||||||
|
risk_whitelist=risk_wl,
|
||||||
|
risk_blacklist=risk_bl,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
gemini_key = (
|
gemini_key = (
|
||||||
@@ -128,6 +132,9 @@ async def _process_event(username: str, body: dict, cfg: dict) -> None:
|
|||||||
tool_list=tool_list,
|
tool_list=tool_list,
|
||||||
confirm_allow=c_allow,
|
confirm_allow=c_allow,
|
||||||
confirm_deny=c_deny,
|
confirm_deny=c_deny,
|
||||||
|
max_risk=max_risk,
|
||||||
|
risk_whitelist=risk_wl,
|
||||||
|
risk_blacklist=risk_bl,
|
||||||
)
|
)
|
||||||
response_text = result.response
|
response_text = result.response
|
||||||
backend = result.backend
|
backend = result.backend
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import logging
|
|||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response
|
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 context_loader import load_context
|
||||||
from llm_client import complete
|
from llm_client import complete
|
||||||
from notification import _send_nct_message
|
from notification import _send_nct_message
|
||||||
@@ -95,6 +95,7 @@ async def _process_message(
|
|||||||
policy = get_tool_policy(username)
|
policy = get_tool_policy(username)
|
||||||
c_allow = set(policy.get("allow", []))
|
c_allow = set(policy.get("allow", []))
|
||||||
c_deny = set(policy.get("deny", []))
|
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":
|
if orch_model and orch_model.get("type") == "local_openai":
|
||||||
result = await openai_orchestrator.run(
|
result = await openai_orchestrator.run(
|
||||||
@@ -106,6 +107,9 @@ async def _process_message(
|
|||||||
tool_list=tool_list,
|
tool_list=tool_list,
|
||||||
confirm_allow=c_allow,
|
confirm_allow=c_allow,
|
||||||
confirm_deny=c_deny,
|
confirm_deny=c_deny,
|
||||||
|
max_risk=max_risk,
|
||||||
|
risk_whitelist=risk_wl,
|
||||||
|
risk_blacklist=risk_bl,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
gemini_key = (
|
gemini_key = (
|
||||||
@@ -124,6 +128,9 @@ async def _process_message(
|
|||||||
tool_list=tool_list,
|
tool_list=tool_list,
|
||||||
confirm_allow=c_allow,
|
confirm_allow=c_allow,
|
||||||
confirm_deny=c_deny,
|
confirm_deny=c_deny,
|
||||||
|
max_risk=max_risk,
|
||||||
|
risk_whitelist=risk_wl,
|
||||||
|
risk_blacklist=risk_bl,
|
||||||
)
|
)
|
||||||
|
|
||||||
response_text = result.response
|
response_text = result.response
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from datetime import datetime, timezone
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
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 config import settings
|
||||||
from context_loader import load_context
|
from context_loader import load_context
|
||||||
from persona import set_context, validate as validate_persona
|
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)
|
policy = get_tool_policy(user)
|
||||||
confirm_allow = set(policy.get("allow", []))
|
confirm_allow = set(policy.get("allow", []))
|
||||||
confirm_deny = set(policy.get("deny", []))
|
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":
|
if orch_model and orch_model.get("type") == "local_openai":
|
||||||
result = await openai_orchestrator.run(
|
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,
|
tool_list=tool_list,
|
||||||
confirm_allow=confirm_allow,
|
confirm_allow=confirm_allow,
|
||||||
confirm_deny=confirm_deny,
|
confirm_deny=confirm_deny,
|
||||||
|
max_risk=max_risk,
|
||||||
|
risk_whitelist=risk_wl,
|
||||||
|
risk_blacklist=risk_bl,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
gemini_key = (
|
gemini_key = (
|
||||||
@@ -255,6 +259,9 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
|
|||||||
confirm_allow=confirm_allow,
|
confirm_allow=confirm_allow,
|
||||||
confirm_deny=confirm_deny,
|
confirm_deny=confirm_deny,
|
||||||
max_rounds=orch_model.get("max_rounds") if orch_model else None,
|
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:
|
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="{{ help_href }}" class="nav-link">Help</a>
|
||||||
<a href="/settings" class="nav-link">Settings</a>
|
<a href="/settings" class="nav-link">Settings</a>
|
||||||
<a href="/settings/notifications" class="nav-link active">Notifications</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>
|
<span class="nav-spacer"></span>
|
||||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -265,6 +265,7 @@
|
|||||||
<a href="{{ help_href }}" class="nav-link">Help</a>
|
<a href="{{ help_href }}" class="nav-link">Help</a>
|
||||||
<a href="/settings" class="nav-link active">Settings</a>
|
<a href="/settings" class="nav-link active">Settings</a>
|
||||||
<a href="/settings/notifications" class="nav-link">Notifications</a>
|
<a href="/settings/notifications" class="nav-link">Notifications</a>
|
||||||
|
<a href="/settings/tools" class="nav-link">Tools</a>
|
||||||
<span class="nav-spacer"></span>
|
<span class="nav-spacer"></span>
|
||||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||||
</nav>
|
</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 ─────────────────────────────────────────────────
|
# ── 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(
|
def get_tools_for_role(
|
||||||
role: str,
|
role: str,
|
||||||
tool_list: list[str] | None = None,
|
tool_list: list[str] | None = None,
|
||||||
|
max_risk: str | None = None,
|
||||||
|
whitelist: list[str] | None = None,
|
||||||
|
blacklist: list[str] | None = None,
|
||||||
) -> tuple[list, dict]:
|
) -> 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
|
role — user access level ("user" | "admin"); gates admin-only tools
|
||||||
tool_list — optional explicit allow-list from role config (e.g. coder role);
|
tool_list — optional model-level allow-list; intersected so it can only restrict
|
||||||
intersected with the access-level filter so it can only restrict,
|
max_risk — auto-include tools at/below this risk level ("low"|"medium"|"high")
|
||||||
never elevate privileges
|
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 = {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:
|
if tool_list is not None:
|
||||||
allowed &= set(tool_list)
|
allowed &= set(tool_list)
|
||||||
decls = [d for d in _ALL_DECLARATIONS if d.name in allowed]
|
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(
|
def get_openai_tools_for_role(
|
||||||
role: str,
|
role: str,
|
||||||
tool_list: list[str] | None = None,
|
tool_list: list[str] | None = None,
|
||||||
|
max_risk: str | None = None,
|
||||||
|
whitelist: list[str] | None = None,
|
||||||
|
blacklist: list[str] | None = None,
|
||||||
) -> list[dict]:
|
) -> 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")
|
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 = {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:
|
if tool_list is not None:
|
||||||
allowed &= set(tool_list)
|
allowed &= set(tool_list)
|
||||||
return [t for t in OPENAI_TOOL_SCHEMAS if t["function"]["name"] in allowed]
|
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) |
|
| `event_bus.py` | Internal SSE pub/sub (NC Talk → browser mirror) |
|
||||||
| `email_utils.py` | SMTP invite emails |
|
| `email_utils.py` | SMTP invite emails |
|
||||||
| `persona_template.py` | Bootstrap a new persona directory from templates |
|
| `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` |
|
| `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/` | 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) |
|
| `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` |
|
| `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 |
|
| `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.
|
**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`.
|
**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.
|
**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
|
- FastAPI service with streaming SSE response
|
||||||
- Claude CLI and Gemini CLI subprocess backends
|
- Claude CLI and Gemini CLI subprocess backends
|
||||||
- Session context management (rolling window, `MAX_HISTORY_MESSAGES`)
|
- 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