fix(runtime): enforce local control across Cookbook wrappers

This commit is contained in:
Alexandre Teixeira
2026-10-02 23:39:13 +01:00
parent bcc0e54e1b
commit 3834cd72b1
6 changed files with 68 additions and 10 deletions
+11
View File
@@ -118,6 +118,17 @@ def _require_cookbook_scope(request: Request, allowed: set[str]) -> str:
because cookbook surfaces expose host topology, task logs, tmux because cookbook surfaces expose host topology, task logs, tmux
commands, and model-serving controls. commands, and model-serving controls.
""" """
# Internal transport/owner attribution is not a scoped external credential.
# In no-login mode, this wrapper must preserve the native local-operator
# boundary even though it invokes endpoint functions without dependencies.
from src.agent_runtime.authority import is_internal_tool_request
from src.auth_helpers import _auth_disabled
from core.middleware import INTERNAL_TOOL_HEADER
if is_internal_tool_request(request) or request.headers.get(INTERNAL_TOOL_HEADER):
raise HTTPException(403, "Internal Cookbook calls require a dedicated producer")
if _auth_disabled():
from routes.shell_routes import _require_admin
_require_admin(request)
owner = _scope_owner(request, allowed) owner = _scope_owner(request, allowed)
if not getattr(request.state, "api_token", False): if not getattr(request.state, "api_token", False):
require_admin(request) require_admin(request)
+9
View File
@@ -426,6 +426,13 @@ def setup_cookbook_routes() -> APIRouter:
if not claimed: if not claimed:
_require_admin(request) _require_admin(request)
router = APIRouter(tags=["cookbook"], dependencies=[Depends(protect_native_control)]) router = APIRouter(tags=["cookbook"], dependencies=[Depends(protect_native_control)])
def protect_local_model_producer(request, remote_host):
# Scoped wrappers can call endpoint functions directly, without FastAPI
# dependencies. Enforce native control at the actual producer as well.
if not remote_host and getattr(request.state, "local_model_authority", None) is None:
from routes.shell_routes import _require_admin
_require_admin(request)
_cookbook_state_path = Path(COOKBOOK_STATE_FILE) _cookbook_state_path = Path(COOKBOOK_STATE_FILE)
_state_get_cache = {"ts": 0.0, "mtime": 0.0, "value": None} _state_get_cache = {"ts": 0.0, "mtime": 0.0, "value": None}
_tasks_status_cache = {"ts": 0.0, "value": None} _tasks_status_cache = {"ts": 0.0, "value": None}
@@ -1100,6 +1107,7 @@ def setup_cookbook_routes() -> APIRouter:
"""Download a HuggingFace model in a tmux session. """Download a HuggingFace model in a tmux session.
Uses `hf download` CLI directly — runs in tmux via `script -qc` Uses `hf download` CLI directly — runs in tmux via `script -qc`
for real TTY progress, streams ANSI-stripped output via log file.""" for real TTY progress, streams ANSI-stripped output via log file."""
protect_local_model_producer(request, req.remote_host)
require_admin(request) require_admin(request)
# Defence-in-depth: even though this endpoint is admin-gated, refuse # Defence-in-depth: even though this endpoint is admin-gated, refuse
# values that would land in shell contexts with metacharacters. # values that would land in shell contexts with metacharacters.
@@ -2018,6 +2026,7 @@ def setup_cookbook_routes() -> APIRouter:
keep strict validation, but serving local cached models must not require keep strict validation, but serving local cached models must not require
a fake org/name wrapper. a fake org/name wrapper.
""" """
protect_local_model_producer(request, req.remote_host)
require_admin(request) require_admin(request)
# Defence-in-depth: reject values that could break out of shell contexts. # Defence-in-depth: reject values that could break out of shell contexts.
validate_remote_host(req.remote_host) validate_remote_host(req.remote_host)
+2
View File
@@ -260,6 +260,8 @@ def needs_owned_binding(operation):
"notes", "memory", "vault", "upload", "uploads", "attachments", "notes", "memory", "vault", "upload", "uploads", "attachments",
"shell", "model", "cookbook"} "shell", "model", "cookbook"}
segments = path.strip("/").split("/") segments = path.strip("/").split("/")
if len(segments) >= 3 and segments[:3] == ["api", "codex", "cookbook"]:
raise ResourceIdentityError("Cookbook wrappers require a dedicated resource-bound tool")
if len(segments) >= 2 and segments[0] == "api" and segments[1].casefold() in private: if len(segments) >= 2 and segments[0] == "api" and segments[1].casefold() in private:
raise ResourceIdentityError("Owned records require a dedicated resource-bound tool") raise ResourceIdentityError("Owned records require a dedicated resource-bound tool")
return False return False
+8 -2
View File
@@ -1,4 +1,5 @@
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from types import SimpleNamespace
import pytest import pytest
@@ -11,6 +12,11 @@ from src.host_docker_access import HOST_DOCKER_ACCESS_HINT
from tests.helpers.unix_sockets import bound_unix_socket from tests.helpers.unix_sockets import bound_unix_socket
@pytest.fixture(autouse=True)
def authenticated_admin_mode(monkeypatch):
monkeypatch.setenv("AUTH_ENABLED", "true")
def _model_serve_endpoint(): def _model_serve_endpoint():
router = cookbook_routes.setup_cookbook_routes() router = cookbook_routes.setup_cookbook_routes()
for route in router.routes: for route in router.routes:
@@ -27,6 +33,8 @@ def _admin_request() -> Request:
"path": "/api/model/serve", "path": "/api/model/serve",
"headers": [], "headers": [],
"state": {}, "state": {},
"app": SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(
is_configured=True, is_admin=lambda user: user == "admin"))),
} }
) )
request.state.current_user = "admin" request.state.current_user = "admin"
@@ -139,7 +147,6 @@ async def test_local_container_serve_returns_host_docker_opt_in_hint(
assert cookbook_routes.shutil.which(binary) == "/usr/bin/docker" assert cookbook_routes.shutil.which(binary) == "/usr/bin/docker"
return False return False
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available) monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available)
monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True) monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True)
monkeypatch.setattr( monkeypatch.setattr(
@@ -199,7 +206,6 @@ async def test_local_container_serve_allows_generated_docker_exec_when_enabled(
launched_commands.append(command) launched_commands.append(command)
return _Process() return _Process()
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available) monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available)
monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True) monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True)
monkeypatch.setattr( monkeypatch.setattr(
+4 -8
View File
@@ -1,13 +1,12 @@
"""Permanent linkage loss suppresses continuation without granting authority.""" """Permanent linkage loss suppresses continuation without granting authority."""
import asyncio from types import SimpleNamespace
import sys
from types import ModuleType, SimpleNamespace
import time import time
import pytest import pytest
from src import bg_jobs, bg_monitor from src import bg_jobs, bg_monitor
from src.agent_runtime import process_resources as resources from src.agent_runtime import process_resources as resources
from tests.test_background_resource_identity import store, seed from tests.test_background_resource_identity import store, seed
from src.agent_runtime.resources import ResourceIdentityError
@pytest.fixture @pytest.fixture
@@ -15,8 +14,8 @@ def monitor_session(monkeypatch):
messages = [] messages = []
sess = SimpleNamespace(id='thread', owner='alice', model='test-model', get_context_messages=lambda: []) sess = SimpleNamespace(id='thread', owner='alice', model='test-model', get_context_messages=lambda: [])
sm = SimpleNamespace(get_session=lambda sid: sess, add_message=lambda *args: messages.append(args), save_sessions=lambda: None) sm = SimpleNamespace(get_session=lambda sid: sess, add_message=lambda *args: messages.append(args), save_sessions=lambda: None)
ai = ModuleType('src.ai_interaction'); ai.get_session_manager = lambda: sm import src.ai_interaction as ai
monkeypatch.setitem(sys.modules, 'src.ai_interaction', ai) monkeypatch.setattr(ai, 'get_session_manager', lambda: sm)
import src.agent_runs import src.agent_runs
monkeypatch.setattr(src.agent_runs, 'is_active', lambda sid: False) monkeypatch.setattr(src.agent_runs, 'is_active', lambda sid: False)
async def drain(*args, **kwargs): async def drain(*args, **kwargs):
@@ -49,9 +48,6 @@ async def test_invalid_linkage_is_terminal_without_message(store, monkeypatch, m
resources.validate_job(resource) resources.validate_job(resource)
from src.agent_runtime.resources import ResourceIdentityError
async def test_busy_session_retries_then_continues(store, monkeypatch, monitor_session): async def test_busy_session_retries_then_continues(store, monkeypatch, monitor_session):
_, rec = seed(store, status='done') _, rec = seed(store, status='done')
import src.agent_runs import src.agent_runs
+34
View File
@@ -207,3 +207,37 @@ async def test_auth_disabled_local_route_usage(control_app, monkeypatch):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=('192.0.2.1', 1)), base_url='http://127.0.0.1') as client: async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=('192.0.2.1', 1)), base_url='http://127.0.0.1') as client:
for path, body in [('/api/shell/exec', {'command': ''}), ('/api/cookbook/state', {'tasks': []}), ('/api/model/download', {'repo_id': 'org/model'})]: for path, body in [('/api/shell/exec', {'command': ''}), ('/api/cookbook/state', {'tasks': []}), ('/api/model/download', {'repo_id': 'org/model'})]:
assert (await client.post(path, json=body)).status_code == 403 assert (await client.post(path, json=body)).status_code == 403
@pytest.mark.parametrize('host,internal', [('127.0.0.1', True), ('192.0.2.1', False)])
async def test_scoped_wrapper_cannot_bypass_native_control(control_app, monkeypatch, host, internal):
from routes.codex_routes import setup_codex_routes
app, spawned, _ = control_app
app.include_router(setup_codex_routes())
monkeypatch.setenv('AUTH_ENABLED', 'false')
headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN} if internal else {}
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 123)), base_url='http://127.0.0.1') as client:
r = await client.post('/api/codex/cookbook/serve', json={'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg'}, headers=headers)
assert r.status_code == 403 and not spawned
@pytest.mark.parametrize('path', ['/api/codex/cookbook/serve', '/api/codex/cookbook/stop/job', '/api/codex/%63ookbook/serve'])
async def test_generic_app_api_cannot_substitute_scoped_wrapper(control_app, path):
_, spawned, work = control_app
authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant('app_api'),))
content = json.dumps({'action': 'call', 'method': 'POST', 'path': path, 'body': {'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg'}})
_, result = await tool_execution.execute_tool_block(ToolBlock('app_api', content), owner='alice',
session_id='thread', workspace=str(work), request_authority=authority, security_context=ToolRunSecurityContext())
assert result['failure_kind'] == 'resource_identity_denied' and not spawned
async def test_direct_endpoint_call_keeps_producer_gate(control_app, monkeypatch):
from routes.cookbook_helpers import ModelDownloadRequest
app, spawned, _ = control_app
router = cookbook_routes.setup_cookbook_routes()
endpoint = next(route.endpoint for route in router.routes if getattr(route, 'path', '') == '/api/model/download')
monkeypatch.setenv('AUTH_ENABLED', 'false')
req = request('192.0.2.1')
with pytest.raises(HTTPException) as exc:
await endpoint(req, ModelDownloadRequest(repo_id='org/model'))
assert exc.value.status_code == 403 and not spawned