mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 17:25:26 -04:00
fix: block app_api access to Cookbook host controls (#3231)
This commit is contained in:
+2
-2
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user