fix: block app_api access to Cookbook host controls (#3231)

This commit is contained in:
RaresKeY
2026-06-07 20:20:11 +03:00
committed by GitHub
parent 00e8084969
commit 3a91c11ff8
5 changed files with 111 additions and 10 deletions
+2 -2
View File
@@ -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:
+17 -6
View File
@@ -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:
+1 -1
View File
@@ -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=<keyword> 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=<keyword> 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.",
}
+1 -1
View File
@@ -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": {
+90
View File
@@ -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)