mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-28 00:32:22 +01:00
feat(security): apply audited PR #6738 remediations and test hardening
- Enforce vision probe SSRF safe local host validation, SafeProbeBackend TOCTOU elimination, and session owner authorization - Authorize session titling endpoints against registered owner configuration - Sanitize sensitive metadata logging from agent loop debug logs - Add async non-blocking execution for spec router tools and thinking decisions - Harden email cache invalidation fencing and thread-safe mutation - Add regression test suites for legacy browser authority, spec contracts, spec router request scoping, and task MCP delivery
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
"""Concurrent Spec decisions must remain bound to their own requests."""
|
||||
import io
|
||||
import json
|
||||
import threading
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
from src import agent_loop, clean_agent_preview, spec_router
|
||||
|
||||
|
||||
def test_concurrent_identical_requests_do_not_share_router_decisions(monkeypatch):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_ROUTER_URL', 'http://router.invalid')
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
|
||||
monkeypatch.setattr(agent_loop, '_configured_model_tool_surface_raw', lambda *a, **k: 'spec')
|
||||
completed = threading.Barrier(2)
|
||||
first_done = threading.Event()
|
||||
|
||||
def response(request, timeout):
|
||||
if threading.current_thread().name.endswith('_0'):
|
||||
choice, probability = 'answer_directly', .99
|
||||
else:
|
||||
assert first_done.wait(2)
|
||||
choice, probability = 't1', .99
|
||||
return io.BytesIO(json.dumps({'answers': {'tool': {
|
||||
'choice': choice, 'probabilities': {choice: probability},
|
||||
}}}).encode())
|
||||
|
||||
monkeypatch.setattr(spec_router.urllib.request, 'urlopen', response)
|
||||
schemas = [{'function': {'name': 'manage_notes', 'parameters': {'properties': {}}}}]
|
||||
|
||||
def request(owner):
|
||||
spec_router.spec_route_tools('same private question', schemas, return_decision=True)
|
||||
if owner == 'alice':
|
||||
first_done.set()
|
||||
completed.wait(timeout=2)
|
||||
return clean_agent_preview.spec_direct_no_tools_p(
|
||||
'same private question', 'http://fixture', 'fixture', owner, {})
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
alice = pool.submit(request, 'alice')
|
||||
bob = pool.submit(request, 'bob')
|
||||
assert alice.result() == .99
|
||||
assert bob.result() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_async_sessions_keep_their_own_decisions(monkeypatch):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_ROUTER_URL', 'http://router.invalid')
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
|
||||
monkeypatch.setattr(agent_loop, '_configured_model_tool_surface_raw', lambda *a, **k: 'spec')
|
||||
|
||||
def response(request, timeout):
|
||||
body = json.loads(request.data)
|
||||
choice = 'answer_directly' if body['state']['user_message'] == 'alice' else 't1'
|
||||
return io.BytesIO(json.dumps({'answers': {'tool': {
|
||||
'choice': choice, 'probabilities': {choice: .99},
|
||||
}}}).encode())
|
||||
|
||||
monkeypatch.setattr(spec_router.urllib.request, 'urlopen', response)
|
||||
ready = asyncio.Event()
|
||||
schemas = [{'function': {'name': 'manage_notes', 'parameters': {'properties': {}}}}]
|
||||
|
||||
async def session(owner):
|
||||
spec_router.spec_route_tools(owner, schemas, return_decision=True)
|
||||
if owner == 'alice':
|
||||
await ready.wait()
|
||||
else:
|
||||
ready.set()
|
||||
return clean_agent_preview.spec_direct_no_tools_p(owner, 'http://fixture', 'fixture', owner, {})
|
||||
|
||||
assert await asyncio.gather(session('alice'), session('bob')) == [.99, None]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('response', [
|
||||
{'choice': 't0', 'probabilities': {'t0': .99}},
|
||||
{'choice': 't1', 'probabilities': {'t1': .99, 't999': .8}},
|
||||
{'choice': 't1', 'probabilities': {'t1': 1.5}},
|
||||
])
|
||||
def test_invalid_router_response_cannot_choose_a_tool(monkeypatch, response):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_ROUTER_URL', 'http://router.invalid')
|
||||
monkeypatch.setattr(spec_router.urllib.request, 'urlopen', lambda *a, **k: io.BytesIO(
|
||||
json.dumps({'answers': {'tool': response}}).encode()))
|
||||
schemas = [{'function': {'name': 'manage_notes', 'parameters': {'properties': {}}}}]
|
||||
picks, decided = spec_router.spec_route_tools('List notes', schemas, return_decision=True)
|
||||
assert picks == set()
|
||||
assert decided is False
|
||||
|
||||
|
||||
def test_disabled_router_clears_previous_turn_decision(monkeypatch):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_ROUTER_URL', 'http://router.invalid')
|
||||
monkeypatch.setattr(spec_router.urllib.request, 'urlopen', lambda *a, **k: io.BytesIO(
|
||||
json.dumps({'answers': {'tool': {'choice': 'answer_directly',
|
||||
'probabilities': {'answer_directly': .99}}}}).encode()))
|
||||
spec_router.spec_route_tools('first request', [], return_decision=True)
|
||||
assert spec_router.latest_decision()['choice'] == 'answer_directly'
|
||||
monkeypatch.delenv('ODYSSEUS_SPEC_ROUTER_URL')
|
||||
assert spec_router.spec_route_tools('second request', [], return_decision=True) == (set(), False)
|
||||
assert spec_router.latest_decision() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_router_does_not_block_event_loop(monkeypatch):
|
||||
"""When the spec router is stalled or retrying, the asyncio event loop remains
|
||||
responsive and concurrent requests can run and complete without blocking."""
|
||||
monkeypatch.setenv("ODYSSEUS_SPEC_ROUTER_URL", "http://router.stall.invalid")
|
||||
monkeypatch.setenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "2.0")
|
||||
|
||||
stall_started = asyncio.Event()
|
||||
unblock_router = asyncio.Event()
|
||||
timeline = []
|
||||
|
||||
class _FakeAsyncResponse:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"answers": {
|
||||
"tool": {
|
||||
"choice": "t1",
|
||||
"probabilities": {"t1": 0.95},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async def fake_async_post(client, url, json=None, headers=None, timeout=None):
|
||||
timeline.append("router_post_start")
|
||||
stall_started.set()
|
||||
# Simulate network latency or waiting for router:
|
||||
# Await unblock event so other tasks must run on the loop while this waits
|
||||
await unblock_router.wait()
|
||||
timeline.append("router_post_finish")
|
||||
return _FakeAsyncResponse()
|
||||
|
||||
monkeypatch.setattr(spec_router.httpx.AsyncClient, "post", fake_async_post)
|
||||
schemas = [{"function": {"name": "manage_notes", "parameters": {"properties": {}}}}]
|
||||
|
||||
async def stalled_routing_task():
|
||||
timeline.append("task_a_start")
|
||||
picks, decided = await spec_router.spec_route_tools_async(
|
||||
"Find notes", schemas, return_decision=True
|
||||
)
|
||||
timeline.append("task_a_end")
|
||||
dec = spec_router.latest_decision()
|
||||
return picks, decided, dec
|
||||
|
||||
async def concurrent_responsive_task():
|
||||
# Wait until the router has started waiting on the event loop
|
||||
await stall_started.wait()
|
||||
timeline.append("task_b_executed")
|
||||
# Task B does work and finishes immediately while router is still stalled!
|
||||
unblock_router.set()
|
||||
return "task_b_done"
|
||||
|
||||
res_b, res_a = await asyncio.gather(
|
||||
concurrent_responsive_task(),
|
||||
stalled_routing_task(),
|
||||
)
|
||||
|
||||
assert res_b == "task_b_done"
|
||||
picks, decided, dec = res_a
|
||||
assert picks == {"manage_notes"}
|
||||
assert decided is True
|
||||
assert dec is not None
|
||||
assert dec.get("choice") == "t1"
|
||||
# Verify task_b ran and finished while task_a was waiting on the post
|
||||
assert timeline == [
|
||||
"task_a_start",
|
||||
"router_post_start",
|
||||
"task_b_executed",
|
||||
"router_post_finish",
|
||||
"task_a_end",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_router_cancellation_propagates(monkeypatch):
|
||||
"""When a request cancels, spec_route_tools_async cancels cleanly."""
|
||||
monkeypatch.setenv("ODYSSEUS_SPEC_ROUTER_URL", "http://router.stall.invalid")
|
||||
|
||||
async def hanging_post(*a, **k):
|
||||
await asyncio.sleep(10.0)
|
||||
|
||||
monkeypatch.setattr(spec_router.httpx.AsyncClient, "post", hanging_post)
|
||||
task = asyncio.create_task(
|
||||
spec_router.spec_route_tools_async("hello", [], return_decision=True)
|
||||
)
|
||||
await asyncio.sleep(0.01)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
Reference in New Issue
Block a user