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:
Alexandre Teixeira
2026-10-27 17:25:25 +00:00
parent 3246ae5d9c
commit 0133de6f0e
43 changed files with 2187 additions and 673 deletions
+79 -21
View File
@@ -583,15 +583,25 @@ async def auto_name_session(session_manager, sess):
except Exception:
_is_spec_model = False
if _is_spec_model and endpoint_url:
_headers = getattr(sess, "headers", None)
if isinstance(_headers, str):
try:
_headers = json.loads(_headers)
except Exception:
_headers = None
try:
from src.database import SessionLocal
from src.endpoint_resolver import (build_chat_url, build_headers,
resolve_endpoint_runtime, resolve_owner_registered_endpoint)
from src.owner_identity import auth_disabled
owner = getattr(sess, "owner", None)
if not owner and not auth_disabled():
raise ValueError("Session has no owner")
db = SessionLocal()
try:
endpoint = resolve_owner_registered_endpoint(db, endpoint_url, owner)
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
title_url = build_chat_url(base)
title_headers = build_headers(key, base)
finally:
db.close()
title = await llm_call_async(
endpoint_url,
title_url,
model_name,
[
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."},
@@ -599,7 +609,7 @@ async def auto_name_session(session_manager, sess):
],
temperature=0.3,
max_tokens=32,
headers=_headers or None,
headers=title_headers,
timeout=20,
thinking_mode="off",
)
@@ -625,6 +635,14 @@ async def auto_name_session(session_manager, sess):
return
owner = getattr(sess, "owner", None)
from src.owner_identity import auth_disabled
if not owner and not auth_disabled():
title = fallback_session_title(first_msg)
session_manager.update_session_name(sess.id, title)
logger.info(f"Auto-named unowned session {sess.id} deterministically: {title}")
return
from src.task_endpoint import resolve_task_endpoint
t_url, t_model, t_headers = resolve_task_endpoint(
sess.endpoint_url, sess.model, sess.headers, owner=owner
)
@@ -632,24 +650,64 @@ async def auto_name_session(session_manager, sess):
logger.debug("[auto-name] No model provided, skipping")
return
sess_url = getattr(sess, "endpoint_url", None)
if t_url and sess_url and t_url == sess_url:
from src.database import SessionLocal
from src.endpoint_resolver import (
build_chat_url,
build_headers,
resolve_endpoint,
resolve_endpoint_runtime,
resolve_owner_registered_endpoint,
)
db = SessionLocal()
try:
endpoint = resolve_owner_registered_endpoint(db, t_url, owner)
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
t_url = build_chat_url(base)
t_headers = build_headers(key, base)
except Exception as e:
logger.debug("[auto-name] session fallback endpoint %s unauthorized for %s: %s", t_url, owner, e)
t_url, t_model, t_headers = None, None, None
for prefix in ("utility", "default"):
try:
u, m, h = resolve_endpoint(prefix, owner=owner)
if u and m:
t_url, t_model, t_headers = u, m, h
break
except Exception:
pass
finally:
db.close()
if not t_url or not t_model:
title = fallback_session_title(first_msg)
session_manager.update_session_name(sess.id, title)
logger.info(f"Auto-named session {sess.id} deterministically (no authorized model endpoint): {title}")
return
# max_tokens big enough that reasoning models (Minimax M2,
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
# plus the actual title — 200 used to clip them mid-reasoning
# so strip_think left an empty string and no rename happened.
# Timeout matches: 60s gives slow local reasoners room to finish.
title = await llm_call_async(
t_url,
t_model,
[
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation — just the title."},
{"role": "user", "content": first_msg},
],
temperature=0.3,
max_tokens=64,
headers=t_headers,
timeout=15,
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
)
try:
title = await llm_call_async(
t_url,
t_model,
[
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation — just the title."},
{"role": "user", "content": first_msg},
],
temperature=0.3,
max_tokens=64,
headers=t_headers,
timeout=15,
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
)
except Exception as e:
logger.warning("[auto-name] non-spec title call failed for %s: %s", sess.id, e)
title = ""
title = title.strip().strip('"\'').strip()
# Strip <think>/<thinking> blocks (closed, dangling, or stray tags)