diff --git a/src/agent_loop.py b/src/agent_loop.py index b358f6a00..587f5105f 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -363,7 +363,7 @@ GENERIC LOOPBACK to allowed Odysseus internal endpoints. Use this whenever the u **Common surfaces (use `endpoints` with filter to discover the full set per domain):** - Calendar: `/api/calendar/events`, `/api/calendar/calendars`, `/api/calendar/events/{uid}` -- Cookbook: `/api/cookbook/gpus`, `/api/cookbook/state`, `/api/cookbook/setup`, `/api/cookbook/kill-pid`, `/api/cookbook/packages`, `/api/cookbook/hf-latest`, `/api/model/cached` +- Cookbook: `/api/cookbook/gpus`, `/api/cookbook/state`, `/api/cookbook/setup`, `/api/cookbook/packages`, `/api/cookbook/hf-latest`, `/api/model/cached`. Do NOT use `app_api` for package installs, engine rebuilds, or PID signalling. - Gallery: `/api/gallery/list`, `/api/gallery/delete`, `/api/gallery/{id}`, `/api/gallery/albums` - Library / Documents: list all via `/api/documents/library`; docs in a session via `/api/documents/{session_id}`; a single doc via `/api/document/{id}` (singular) and its history via `/api/document/{id}/versions` (singular). Note the plural `/api/documents/...` vs singular `/api/document/{id}` split. - Memory: `/api/memory`, `/api/memory/{id}`, `/api/memory/search` @@ -382,7 +382,7 @@ Body for POST/PUT/PATCH goes in `body` (object). Query params in `query` (object **When to prefer named tools over app_api:** if a named wrapper exists (list_email_accounts, list_emails, read_email, manage_calendar, manage_notes, list_served_models, etc.) USE IT — it has nicer output formatting and clearer schema. Reach for `app_api` only when there's no wrapper for what you need. -Blocked paths (refused for safety): /api/auth/, /api/users/, /api/tokens/, /api/admin/, /api/shell/, /api/backup/restore, /api/email/accounts.""", +Blocked paths/routes (refused for safety): /api/auth/, /api/users/, /api/tokens/, /api/admin/, /api/shell/, /api/backup/restore, /api/email/accounts, POST /api/cookbook/packages/install, POST /api/cookbook/rebuild-engine, POST /api/cookbook/kill-pid.""", } def get_builtin_overrides() -> dict: diff --git a/src/tool_implementations.py b/src/tool_implementations.py index 48eed6d4c..0823f2190 100644 --- a/src/tool_implementations.py +++ b/src/tool_implementations.py @@ -2720,14 +2720,19 @@ _APP_API_BLOCKLIST_PREFIXES = ( ) # (method, prefix) pairs to refuse specifically. Used for endpoints -# where GET is fine but writes are destructive — saw the agent wipe -# cookbook_state.json (presets + tasks) by POSTing {"tasks": []} to -# /api/cookbook/state, which overwrote the whole file. Use the -# dedicated preset/task tools instead. +# where GET is fine but writes are destructive or host-control shaped. +# Saw the agent wipe cookbook_state.json (presets + tasks) by POSTing +# {"tasks": []} to /api/cookbook/state, which overwrote the whole file. +# Use dedicated tools or UI flows instead. _APP_API_BLOCKLIST_METHOD_PATH = ( ("GET", "/api/email/accounts"), # owner-filtered in tool context; use list_email_accounts MCP tool ("POST", "/api/cookbook/state"), # whole-file overwrite — agent must use serve_preset/serve_model instead ("DELETE", "/api/cookbook/state"), + # Host-control routes: package install, engine rebuild, and process + # signalling should not be reachable through the generic API bridge. + ("POST", "/api/cookbook/packages/install"), + ("POST", "/api/cookbook/rebuild-engine"), + ("POST", "/api/cookbook/kill-pid"), # Use the named tools (download_model / serve_model) — they handle # host-name resolution, per-host env_prefix, AND register the task # in cookbook state so it shows in the UI + list_downloads. Hitting @@ -2766,8 +2771,8 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict: The `endpoints` action returns the OpenAPI surface (method + path + summary) so the agent can discover what's reachable. A blocklist - refuses sensitive auth/user/admin/shell paths to keep blast radius - bounded. + refuses sensitive auth/user/admin/shell paths and method-specific + host-control routes to keep blast radius bounded. """ import httpx try: @@ -2835,6 +2840,12 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict: if any(method == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH): if "/api/email/accounts" in path: return {"error": "Don't use /api/email/accounts via app_api — it is owner-filtered in tool context and may return empty. Use the `list_email_accounts` email tool, then pass `account` to list_emails/read_email.", "exit_code": 1} + if "/api/cookbook/packages/install" in path: + return {"error": "Don't POST /api/cookbook/packages/install via app_api — package installation is host code execution. Use the dedicated Cookbook dependency UI/flow instead.", "exit_code": 1} + if "/api/cookbook/rebuild-engine" in path: + return {"error": "Don't POST /api/cookbook/rebuild-engine via app_api — engine rebuild mutates local or remote host state. Use the dedicated Cookbook UI/flow instead.", "exit_code": 1} + if "/api/cookbook/kill-pid" in path: + return {"error": "Don't POST /api/cookbook/kill-pid via app_api — process signalling is host control. Use the dedicated Cookbook stop/diagnostic flow instead.", "exit_code": 1} if "/api/model/download" in path: return {"error": "Don't POST /api/model/download directly — use the `download_model` tool (it resolves the server name, sets the venv env_prefix, and registers the task so it shows in the UI).", "exit_code": 1} if "/api/model/serve" in path: diff --git a/src/tool_index.py b/src/tool_index.py index 2db125447..20b7d04a2 100644 --- a/src/tool_index.py +++ b/src/tool_index.py @@ -153,7 +153,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = { "serve_preset": "Launch a saved Cookbook serve preset by name. Reuses the exact tmux command + host the user already saved. Use for 'run stable diffusion 3.5', 'serve vllm-qwen', 'start the inpaint model' — preset-name matches the user's UI labels.", "adopt_served_model": "Register an existing tmux model server (one started manually or outside the cookbook flow) into Cookbook tracking AND add it as a chat endpoint. Use when the user (or a previous turn) launched something via ssh+tmux and now wants it visible in the UI, stoppable via stop_served_model, and usable in the model picker.", "list_cookbook_servers": "List the cookbook's configured servers (remote GPU boxes + local) and which is the current default. Use this BEFORE download_model/serve_model when the user didn't name a host — to decide where to run, or to ask the user which server when ambiguous. Downloads/serves default to the cookbook's selected server, NOT localhost.", - "app_api": "Generic loopback to allowed Odysseus internal endpoints. Use this when the user wants something the UI can do but there's no named tool for it. Covers calendar, gallery, library/documents, memory, notes, tasks, settings, research, compare, cookbook GPUs/state — every allowed UI button hits some /api/* endpoint and you can hit it too. Sensitive auth/user/admin/shell paths are blocked; do NOT use app_api for shell commands, use named command tooling instead. action='endpoints' with filter= lists available endpoints. action='call' takes method+path+body. Hits same routes the UI uses — auth flows free. NOTE: themes are NOT an API endpoint — use the ui_control tool (create_theme / set_theme), not app_api. SESSIONS/CHATS: do NOT use app_api for these — GET /api/sessions returns EMPTY for tool calls (it's owner-filtered and tool calls authenticate as a different identity). EMAIL ACCOUNTS: do NOT use /api/email/accounts via app_api; use list_email_accounts, list_emails, and read_email instead. To list/rename/archive/delete/fork chats use the list_sessions and manage_session tools instead.", + "app_api": "Generic loopback to allowed Odysseus internal endpoints. Use this when the user wants something the UI can do but there's no named tool for it. Covers calendar, gallery, library/documents, memory, notes, tasks, settings, research, compare, cookbook GPUs/state — allowed UI buttons hit /api/* endpoints and you can hit them too. Sensitive auth/user/admin/shell paths and host-control Cookbook mutation routes are blocked; do NOT use app_api for shell commands, package installs, engine rebuilds, or PID signalling. Use named command tooling for shell commands. action='endpoints' with filter= lists available endpoints. action='call' takes method+path+body. Hits same routes the UI uses — auth flows free. NOTE: themes are NOT an API endpoint — use the ui_control tool (create_theme / set_theme), not app_api. SESSIONS/CHATS: do NOT use app_api for these — GET /api/sessions returns EMPTY for tool calls (it's owner-filtered and tool calls authenticate as a different identity). EMAIL ACCOUNTS: do NOT use /api/email/accounts via app_api; use list_email_accounts, list_emails, and read_email instead. To list/rename/archive/delete/fork chats use the list_sessions and manage_session tools instead.", "edit_image": "Edit an image in the gallery: upscale (increase resolution), remove background (rembg), inpaint (fill selected area), or harmonize (blend edits). Specify image ID and action.", "trigger_research": "Start a deep research job on any topic — appears in the Deep Research sidebar, streams progress, produces a detailed report. Use for 'research X', 'look into Y', 'do deep research on Z', 'investigate'. NOT a scheduled task — it runs now and surfaces in the sidebar.", } diff --git a/src/tool_schemas.py b/src/tool_schemas.py index 6b8be74fd..750b60b50 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -950,7 +950,7 @@ FUNCTION_TOOL_SCHEMAS = [ "type": "function", "function": { "name": "app_api", - "description": "Generic loopback to allowed internal Odysseus endpoints. Use this when there's no named tool for what the user wants. Hits the same routes the UI buttons hit (cookbook, gallery, library/documents, memory, notes, calendar, tasks, settings, themes, research, compare, etc.). action='endpoints' returns the OpenAPI surface (use `filter` to narrow). action='call' (default) takes method+path+body. Sensitive auth/user/admin/shell paths are blocked for safety. Do not use for shell commands; use named command tooling instead. Do not use for email account discovery; use list_email_accounts instead because /api/email/accounts is owner-filtered in tool context.", + "description": "Generic loopback to allowed internal Odysseus endpoints. Use this when there's no named tool for what the user wants. Hits the same routes the UI buttons hit (cookbook, gallery, library/documents, memory, notes, calendar, tasks, settings, themes, research, compare, etc.). action='endpoints' returns the OpenAPI surface (use `filter` to narrow). action='call' (default) takes method+path+body. Sensitive auth/user/admin/shell paths and host-control Cookbook mutation routes are blocked for safety. Do not use for shell commands; use named command tooling instead. Do not use for package installs, engine rebuilds, PID signalling, or email account discovery; use list_email_accounts for email accounts because /api/email/accounts is owner-filtered in tool context.", "parameters": { "type": "object", "properties": { diff --git a/tests/test_review_regressions.py b/tests/test_review_regressions.py index a57000915..cda2c720a 100644 --- a/tests/test_review_regressions.py +++ b/tests/test_review_regressions.py @@ -470,6 +470,52 @@ async def test_app_api_blocks_shell_routes_before_loopback(monkeypatch): assert "Sensitive endpoints" in result["error"] +@pytest.mark.asyncio +async def test_app_api_blocks_cookbook_host_control_routes_before_loopback(monkeypatch): + import httpx + from src.tool_implementations import do_app_api + + class UnexpectedAsyncClient: + def __init__(self, *args, **kwargs): + raise AssertionError("app_api should block host-control routes before loopback") + + monkeypatch.setattr(httpx, "AsyncClient", UnexpectedAsyncClient) + + blocked_calls = ( + ( + "api/cookbook/packages/install", + {"pip": "hf_transfer"}, + "package installation is host code execution", + ), + ( + "/api/cookbook/rebuild-engine", + {"engine": "llamacpp"}, + "engine rebuild mutates local or remote host state", + ), + ( + "/api/cookbook/kill-pid", + {"pid": 12345, "signal": "TERM"}, + "process signalling is host control", + ), + ) + + for path, body, error_text in blocked_calls: + result = await do_app_api( + json.dumps( + { + "action": "call", + "method": "POST", + "path": path, + "body": body, + } + ), + owner="admin", + ) + + assert result["exit_code"] == 1 + assert error_text in result["error"] + + @pytest.mark.asyncio async def test_app_api_endpoint_discovery_hides_shell_routes(monkeypatch): _install_core_middleware_stub(monkeypatch) @@ -513,6 +559,50 @@ async def test_app_api_endpoint_discovery_hides_shell_routes(monkeypatch): assert all(not endpoint["path"].startswith("/api/shell") for endpoint in result["endpoints"]) +@pytest.mark.asyncio +async def test_app_api_endpoint_discovery_hides_cookbook_host_control_routes(monkeypatch): + _install_core_middleware_stub(monkeypatch) + import httpx + from src.tool_implementations import do_app_api + + class FakeResponse: + def json(self): + return { + "paths": { + "/api/cookbook/packages": {"get": {"summary": "List Cookbook Packages"}}, + "/api/cookbook/packages/install": {"post": {"summary": "Install Package"}}, + "/api/cookbook/rebuild-engine": {"post": {"summary": "Rebuild Engine"}}, + "/api/cookbook/kill-pid": {"post": {"summary": "Kill Process"}}, + "/api/cookbook/gpus": {"get": {"summary": "List GPUs"}}, + } + } + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get(self, *args, **kwargs): + return FakeResponse() + + monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient) + + result = await do_app_api(json.dumps({"action": "endpoints", "filter": "cookbook"}), owner="admin") + + assert result["exit_code"] == 0 + paths = {(endpoint["method"], endpoint["path"]) for endpoint in result["endpoints"]} + assert ("GET", "/api/cookbook/packages") in paths + assert ("GET", "/api/cookbook/gpus") in paths + assert ("POST", "/api/cookbook/packages/install") not in paths + assert ("POST", "/api/cookbook/rebuild-engine") not in paths + assert ("POST", "/api/cookbook/kill-pid") not in paths + + @pytest.mark.asyncio async def test_public_agent_policy_blocks_sensitive_tools(monkeypatch): auth_mod = _install_core_auth_stub(monkeypatch)