diff --git a/CLAUDE.md b/CLAUDE.md index 14536c7..e812168 100644 --- a/CLAUDE.md +++ b/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/.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/.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. diff --git a/cortex/auth_utils.py b/cortex/auth_utils.py index 4d5dbb2..b9c4ca6 100644 --- a/cortex/auth_utils.py +++ b/cortex/auth_utils.py @@ -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: - 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 + 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") diff --git a/cortex/main.py b/cortex/main.py index 58c59b1..349faa3 100644 --- a/cortex/main.py +++ b/cortex/main.py @@ -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 diff --git a/cortex/openai_orchestrator.py b/cortex/openai_orchestrator.py index fc37da4..059122b 100644 --- a/cortex/openai_orchestrator.py +++ b/cortex/openai_orchestrator.py @@ -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: diff --git a/cortex/orchestrator_engine.py b/cortex/orchestrator_engine.py index 9a93526..fa1fa34 100644 --- a/cortex/orchestrator_engine.py +++ b/cortex/orchestrator_engine.py @@ -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) diff --git a/cortex/routers/homeassistant.py b/cortex/routers/homeassistant.py index 819e18b..b25f797 100644 --- a/cortex/routers/homeassistant.py +++ b/cortex/routers/homeassistant.py @@ -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 diff --git a/cortex/routers/nextcloud_talk.py b/cortex/routers/nextcloud_talk.py index acd18c0..d78f22b 100644 --- a/cortex/routers/nextcloud_talk.py +++ b/cortex/routers/nextcloud_talk.py @@ -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 diff --git a/cortex/routers/orchestrator.py b/cortex/routers/orchestrator.py index 0c891f7..8d67e25 100644 --- a/cortex/routers/orchestrator.py +++ b/cortex/routers/orchestrator.py @@ -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: diff --git a/cortex/routers/tools_settings.py b/cortex/routers/tools_settings.py new file mode 100644 index 0000000..847599e --- /dev/null +++ b/cortex/routers/tools_settings.py @@ -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'' + f'{escaped_cat}' + ) + for tool in tools: + risk = TOOL_RISK.get(tool, "medium") + risk_cls = f"risk-{risk}" + risk_html = f'{_html.escape(risk)}' + + # 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'' + + override_sel = ( + f'' + ) + + rows.append( + f'' + f'{_html.escape(tool)}' + f'{risk_html}' + f'' + f'{override_sel}' + f'' + ) + + table_body = "\n".join(rows) + return ( + '' + '' + '' + '' + f'{table_body}' + '
ToolRiskAuto statusOverride
' + ) + + +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("", f'

{success}

') + if error: + html = html.replace("", f'

{error}

') + 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.", + )) diff --git a/cortex/static/notifications.html b/cortex/static/notifications.html index 07e8a71..532f8df 100644 --- a/cortex/static/notifications.html +++ b/cortex/static/notifications.html @@ -210,6 +210,7 @@ Help Settings Notifications + Tools Sign out diff --git a/cortex/static/settings.html b/cortex/static/settings.html index 8285712..b2ac1ed 100644 --- a/cortex/static/settings.html +++ b/cortex/static/settings.html @@ -265,6 +265,7 @@ Help Settings Notifications + Tools Sign out diff --git a/cortex/static/tools_settings.html b/cortex/static/tools_settings.html new file mode 100644 index 0000000..21f8ac7 --- /dev/null +++ b/cortex/static/tools_settings.html @@ -0,0 +1,233 @@ + + + + + + Tool Settings — Cortex + + + + + + +
+

Tool Settings

+

+ Control which orchestrator tools are available. The risk level sets an automatic threshold; + whitelist and blacklist let you fine-tune individual tools beyond that. +

+ + + + +
+ + +
+

Risk Policy

+ +
+ Max risk level + +
+

+ Low tools are read-only and sandboxed (web search, project file reads, HA status checks).
+ Medium tools write to local data or send notifications to you (cron jobs, scratch, task management).
+ High tools affect external systems or the host (shell exec, email, device control, service restart). +

+ +

+ The Auto 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. +

+
+ + +
+ Auto-included by risk level + Auto-excluded by risk level +
+ + +{{ tool_table_html }} + +
+ +
+
+
+ + + + + diff --git a/cortex/tools/__init__.py b/cortex/tools/__init__.py index 3e12925..6f9efdb 100644 --- a/cortex/tools/__init__.py +++ b/cortex/tools/__init__.py @@ -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] diff --git a/documentation/ARCH__SYSTEM.md b/documentation/ARCH__SYSTEM.md index 6946023..50cdf19 100644 --- a/documentation/ARCH__SYSTEM.md +++ b/documentation/ARCH__SYSTEM.md @@ -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. diff --git a/documentation/TODO__Agents.md b/documentation/TODO__Agents.md index 0600ae4..879c7ef 100644 --- a/documentation/TODO__Agents.md +++ b/documentation/TODO__Agents.md @@ -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)