fix(security): isolate bearer authorization paths

This commit is contained in:
RaresKeY
2026-08-28 21:25:23 +00:00
parent 9150a453b4
commit 50c8675a21
24 changed files with 1173 additions and 160 deletions
+85 -7
View File
@@ -16,7 +16,12 @@ from src.llm_core import normalize_model_id
from src.endpoint_resolver import normalize_base
from src.context_compactor import maybe_compact, trim_for_context
from src.model_context import estimate_tokens, get_context_length
from src.auth_helpers import effective_user
from src.auth_helpers import (
RequestCapability,
effective_user,
request_capability as build_request_capability,
)
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
from src.prompt_security import untrusted_context_message
from src.attachment_refs import attachment_ref
from routes.prefs_routes import _load_for_user as load_prefs_for_user
@@ -104,6 +109,33 @@ def _append_incognito_message(session_id: str, role: str, content: Any, metadata
bundle["updated_at"] = time.time()
def _history_for_request_capability(sess, capability: RequestCapability) -> list[dict[str, Any]]:
"""Project persisted history without interactive approval authority for bearers."""
history = sess.get_context_messages()
if not capability.is_bearer:
return history
# Session.get_context_messages() derives the marker only from the separate
# server-owned grant table. A pure bearer chat may still read its owner's
# ordinary transcript, but it must not receive even that interactive
# approval signal as model context or future tool authority.
projected = []
for item in history or []:
if not isinstance(item, dict):
continue
message = dict(item)
metadata = message.get("metadata")
if isinstance(metadata, dict) and CHAT_SESSION_APPROVAL_CONTEXT_MARKER in metadata:
metadata = dict(metadata)
metadata.pop(CHAT_SESSION_APPROVAL_CONTEXT_MARKER, None)
if metadata:
message["metadata"] = metadata
else:
message.pop("metadata", None)
projected.append(message)
return projected
# ── Data containers ────────────────────────────────────────────────────── #
@dataclass
@@ -406,7 +438,13 @@ def build_uploaded_file_manifest(att_ids: list, upload_handler, owner: Optional[
return manifest
def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, incognito: bool = False):
def add_user_message(
sess,
chat_handler,
preprocessed: PreprocessedMessage,
incognito: bool = False,
capability: RequestCapability | None = None,
):
"""Add user message to session history and update session name.
Incognito messages must not mutate persistent session history, even in
memory, because a later normal turn can persist the same session object."""
@@ -414,11 +452,23 @@ def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, inco
return
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
sess.add_message(ChatMessage("user", preprocessed.user_content, metadata=user_meta))
chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context)
if capability is None or capability.allow_auto_naming:
chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context)
def fire_message_event(request, webhook_manager, session_id: str, sess, message: str, compare_mode: bool = False):
def fire_message_event(
request,
webhook_manager,
session_id: str,
sess,
message: str,
compare_mode: bool = False,
capability: RequestCapability | None = None,
):
"""Fire webhook and event_bus events for a new user message."""
capability = capability or build_request_capability(request)
if not capability.allow_message_events:
return
if webhook_manager and not compare_mode:
webhook_manager.fire_and_forget("chat.message", {
"session_id": session_id, "model": sess.model, "message": message[:2000],
@@ -626,12 +676,15 @@ async def build_chat_context(
defer_context_shaping: bool = False,
continuation_context_message: str | None = None,
persist_user_message: bool = True,
capability: RequestCapability | None = None,
) -> ChatContext:
"""Build the full context (preface + messages) for an LLM call.
This is the shared logic between /chat and /chat_stream — preset extraction,
message preprocessing, memory/RAG/web injection, compaction, normalization.
"""
capability = capability or build_request_capability(request)
# Preset
preset = extract_preset(chat_handler, preset_id)
@@ -653,11 +706,25 @@ async def build_chat_context(
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
elif persist_user_message:
add_user_message(sess, chat_handler, preprocessed, incognito=False)
add_user_message(
sess,
chat_handler,
preprocessed,
incognito=False,
capability=capability,
)
# Fire events
if persist_user_message and not incognito:
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
fire_message_event(
request,
webhook_manager,
session_id,
sess,
message,
compare_mode,
capability=capability,
)
# Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
# bearer-token chat requests use the token owner instead of the "api" sentinel.
@@ -761,7 +828,11 @@ async def build_chat_context(
# Build messages. In Nobody/incognito mode, never read saved session
# history: the session id may be a temporary wrapper or, in buggy clients, a
# stale normal session id. Only the ephemeral incognito transcript is safe.
messages = preface + (_incognito_messages(session_id) if incognito else sess.get_context_messages())
messages = preface + (
_incognito_messages(session_id)
if incognito
else _history_for_request_capability(sess, capability)
)
# Current date/time — injected as a standalone *user*-role context message
# placed immediately before the latest user turn, NOT folded into the
@@ -1173,6 +1244,7 @@ def run_post_response_tasks(
owner: str = None,
extract_skills: bool = True,
allow_background_extraction: bool = True,
capability: RequestCapability | None = None,
):
"""Fire background tasks after a completed response: memory extraction, webhooks, auto-name, skill extraction.
@@ -1188,6 +1260,12 @@ def run_post_response_tasks(
``_queue_background_extraction`` keeps them from overlapping the *next*
turn's request too.
"""
if capability is not None and not capability.allow_deferred_work:
# Pure bearer chat is intentionally synchronous and request-bound.
# Do not schedule extraction, teacher/model work, callbacks, or
# auto-naming after the authorized request has returned/disconnected.
return
_extraction_jobs: list = []
# Memory extraction — only every 4th message pair to avoid excess LLM calls
+36 -4
View File
@@ -44,6 +44,7 @@ from src.auth_helpers import (
effective_user,
enforce_api_token_chat_controls,
get_current_user,
request_capability as build_request_capability,
require_chat_scope,
require_interactive_request,
)
@@ -74,6 +75,7 @@ from src.tool_policy import (
web_search_enabled_for_turn,
)
from src.tool_approvals import tool_approval_store
from src.tool_approval_provenance import create_chat_session_approval_grant
logger = logging.getLogger(__name__)
@@ -96,7 +98,11 @@ def _stream_failure_status(chunk: str) -> Optional[int]:
def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
"""Persist a consumed approval decision on its existing tool event."""
"""Persist display-only resolution state on the existing tool event.
This metadata is intentionally never consulted for authorization; the
separate provenance row is written by the interactive approval path.
"""
approval_key = str(approval_id or "")
normalized_decision = str(decision or "").strip().lower()
@@ -786,7 +792,8 @@ def setup_chat_routes(
# non-streaming path can't be used to bypass).
_enforce_chat_privileges(request, sess)
api_token_request = getattr(request.state, "api_token", False) is True
request_capability = build_request_capability(request)
api_token_request = request_capability.is_bearer
tool_policy = build_effective_tool_policy(last_user_message=message)
allow_tool_preprocessing = (
not api_token_request
@@ -817,6 +824,7 @@ def setup_chat_routes(
webhook_manager=webhook_manager,
allow_tool_preprocessing=allow_tool_preprocessing,
defer_context_shaping=foreground_policy.enabled,
capability=request_capability,
)
# Research injection
@@ -925,6 +933,7 @@ def setup_chat_routes(
character_name=ctx.preset.character_name,
owner=ctx.user,
allow_background_extraction=allow_tool_preprocessing,
capability=request_capability,
)
return {
@@ -1003,6 +1012,11 @@ def setup_chat_routes(
approval_id=tool_approval_id,
allow_bash=allow_bash,
)
request_capability = build_request_capability(request)
# Keep the route decision and the downstream capability derived from
# the same verified principal. The explicit control gate above remains
# the source of the chat-mode rejection message.
api_token_request = request_capability.is_bearer
# A bearer token is a chat-only integration credential. Reject every
# remaining control-plane input before approval lookup, intent
@@ -1216,6 +1230,21 @@ def setup_chat_routes(
409,
"This tool approval could not be consumed.",
)
if decision == "approve":
# The transcript card is display data. Only the exact
# interactive, one-use store result may create durable
# session-scope provenance for later tool gates.
if not create_chat_session_approval_grant(
request,
approval=exact_tool_approval,
approval_id=tool_approval_id,
session_id=session,
owner=owner,
):
logger.warning(
"Tool approval %s ran without a durable chat-session grant",
tool_approval_id,
)
if not _mark_tool_approval_resolved(
sess,
tool_approval_id,
@@ -1418,6 +1447,7 @@ def setup_chat_routes(
else None
),
persist_user_message=not tool_approval_continuation,
capability=request_capability,
)
_research_flags = {"do": do_research} # Mutable container for generator scope
@@ -1652,7 +1682,7 @@ def setup_chat_routes(
# Persist session mode after policy/privilege gates so blocked research
# turns remain ordinary chat/agent streams and saved messages.
_effective_mode = 'research' if effective_do_research else (chat_mode or 'chat')
if _effective_mode in ('agent', 'research', 'chat'):
if _effective_mode in ('agent', 'research', 'chat') and not request_capability.is_bearer:
set_session_mode(session, _effective_mode)
async def stream_with_save() -> AsyncGenerator[str, None]:
@@ -2299,6 +2329,7 @@ def setup_chat_routes(
allow_tool_preprocessing
and not tool_approval_continuation
),
capability=request_capability,
)
_stream_set(session, status="done")
yield chunk
@@ -2573,6 +2604,7 @@ def setup_chat_routes(
allow_tool_preprocessing
and not tool_approval_continuation
),
capability=request_capability,
)
_stream_set(session, status="done")
yield chunk
@@ -2647,7 +2679,7 @@ def setup_chat_routes(
# buffered output + live); dropping the SSE only removes a subscriber —
# the run keeps going and saves the assistant message on completion
# regardless. Reconnect via /api/chat/resume.
if compare_mode:
if compare_mode or not request_capability.allow_detached_execution:
return StreamingResponse(_safe_stream(), media_type="text/event-stream")
_detached_run = agent_runs.start(session, _safe_stream())
+27 -17
View File
@@ -1,8 +1,9 @@
"""Codex integration routes.
These are small HTTP surfaces intended for the Codex plugin/MCP bridge. They
reuse existing Odysseus helpers and enforce API-token scopes before touching
user data.
reuse existing Odysseus helpers. The bridge is an interactive host-control
plane and is unavailable to bearer principals; cookie/admin callers retain the
documented operation path.
"""
import asyncio
@@ -12,13 +13,14 @@ from io import BytesIO
from pathlib import Path
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from core.middleware import require_admin
from src.auth_helpers import (
require_api_token_owner,
require_authenticated_request,
require_non_bearer_request,
require_user,
)
from src.tool_implementations import do_manage_notes
@@ -88,6 +90,7 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
def _scope_owner(request: Request, allowed: set[str]) -> str:
"""Return the data owner if the caller is allowed for this Codex action."""
require_non_bearer_request(request)
if getattr(request.state, "api_token", False) is True:
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
if not scopes.intersection(allowed):
@@ -99,6 +102,7 @@ def _scope_owner(request: Request, allowed: set[str]) -> str:
def _scope_owner_all(request: Request, required: set[str]) -> str:
"""Return owner only when an API token has every required scope."""
require_non_bearer_request(request)
if getattr(request.state, "api_token", False) is True:
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
missing = required - scopes
@@ -111,9 +115,9 @@ def _scope_owner_all(request: Request, required: set[str]) -> str:
def _require_cookbook_scope(request: Request, allowed: set[str]) -> str:
"""Authorize a Codex cookbook route.
For API-token callers, enforce the given scope set.
For cookie-session callers, additionally require admin privileges
because cookbook surfaces expose host topology, task logs, tmux
Bearer callers are rejected by the host-control boundary regardless of
legacy scope labels. Cookie-session callers additionally require admin
privileges because cookbook surfaces expose host topology, task logs, tmux
commands, and model-serving controls.
"""
owner = _scope_owner(request, allowed)
@@ -149,7 +153,11 @@ def setup_codex_routes(
calendar_router: APIRouter | None = None,
document_router: APIRouter | None = None,
) -> APIRouter:
router = APIRouter(prefix="/api/codex", tags=["codex"])
router = APIRouter(
prefix="/api/codex",
tags=["codex"],
dependencies=[Depends(require_non_bearer_request)],
)
email_list_endpoint = _find_endpoint(email_router, "GET", "/api/email/list")
email_read_endpoint = _find_endpoint(email_router, "GET", "/api/email/read/{uid}")
email_send_endpoint = _find_endpoint(email_router, "POST", "/api/email/send")
@@ -164,6 +172,7 @@ def setup_codex_routes(
@router.get("/capabilities")
def capabilities(request: Request):
require_non_bearer_request(request)
token_scopes = set(getattr(request.state, "api_token_scopes", []) or [])
has_token = getattr(request.state, "api_token", False) is True
def scoped(allowed):
@@ -215,6 +224,7 @@ def setup_codex_routes(
@router.get("/plugin.zip")
def plugin_zip(request: Request):
require_non_bearer_request(request)
require_authenticated_request(request)
root = Path(__file__).resolve().parent.parent / "integrations" / "codex"
if not root.exists():
@@ -511,15 +521,10 @@ def setup_codex_routes(
return await _as_owner(request, owner, documents_create_endpoint, request, req)
# ── Cookbook surface ──
# Lets the agent run the same launch / monitor / kill loop the user
# would do by hand in the Cookbook UI: read the current task list +
# tmux output, launch a serve task, stop one. Two scopes:
# cookbook:read — list tasks + tail output + list servers
# cookbook:launch — also start/stop serves (host shell exec)
# `cookbook:launch` is genuinely powerful: /api/model/serve runs SSH'd
# commands on the user's hosts. The existing _validate_serve_cmd
# allowlist (vllm/python3/sglang/llama-server/etc., no shell metachars)
# keeps the agent inside the same sandbox the UI uses.
# These handlers retain their legacy scope constants for compatibility
# with callers and tests, but the bridge is now an interactive-only
# host-control plane. Bearer principals are rejected before any task-list,
# tmux-output, launch, stop, or model-serving operation.
async def _run_shell(cmd: str, timeout: float = 15.0) -> dict:
"""Run a shell command, return {exit_code, stdout, stderr}."""
@@ -884,10 +889,15 @@ def setup_claude_routes() -> APIRouter:
this router only exists to deliver the skill zip via `/api/claude/plugin.zip`
so the user-facing setup commands stay in the Claude namespace.
"""
router = APIRouter(prefix="/api/claude", tags=["claude"])
router = APIRouter(
prefix="/api/claude",
tags=["claude"],
dependencies=[Depends(require_non_bearer_request)],
)
@router.get("/plugin.zip")
def plugin_zip(request: Request):
require_non_bearer_request(request)
require_authenticated_request(request)
# Only ship the skills/ subtree so extracting at ~/.claude/ doesn't dump
# README.md or other bundle metadata into the user's claude config dir.
+46 -32
View File
@@ -27,6 +27,24 @@ _HISTORY_INLINE_MEDIA_THRESHOLD = 200_000
_DATA_IMAGE_RE = re.compile(r"data:image/[^;,\"]+;base64,[A-Za-z0-9+/=\s]+")
def _metadata_dict(value: Any) -> dict:
"""Return only mapping-shaped message metadata.
Legacy rows and client payloads can contain JSON lists/scalars. They are
display noise, not trusted fields, and must not reach ``dict.update`` or
approval projection code.
"""
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except (json.JSONDecodeError, TypeError, ValueError):
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _history_display_content(content: Any) -> Any:
"""Return a lightweight browser-display copy of stored message content.
@@ -126,12 +144,9 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
def _db_history_entry(m: DbChatMessage) -> Dict[str, Any]:
entry = {"role": m.role, "content": _history_display_content(m.content)}
meta = {}
if m.meta_data:
try:
meta = json.loads(m.meta_data) or {}
except (json.JSONDecodeError, ValueError):
meta = {}
meta = _metadata_dict(m.meta_data)
if meta:
meta = dict(meta)
if m.timestamp and "timestamp" not in meta:
meta["timestamp"] = m.timestamp.isoformat() + "Z"
if meta:
@@ -199,21 +214,23 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
for msg in session.history:
if isinstance(msg, ChatMessage):
# Skip hidden messages (e.g. compaction summaries for AI context)
if msg.metadata and msg.metadata.get("hidden"):
msg_meta = _metadata_dict(msg.metadata)
if msg_meta.get("hidden"):
continue
entry = {"role": msg.role, "content": _history_display_content(msg.content)}
if msg.metadata:
entry["metadata"] = msg.metadata
if msg_meta:
entry["metadata"] = msg_meta
history_dict.append(entry)
elif isinstance(msg, dict):
if msg.get("metadata", {}).get("hidden"):
msg_meta = _metadata_dict(msg.get("metadata"))
if msg_meta.get("hidden"):
continue
entry = {
"role": msg.get("role", ""),
"content": _history_display_content(msg.get("content", "")),
}
if msg.get("metadata"):
entry["metadata"] = msg["metadata"]
if msg_meta:
entry["metadata"] = msg_meta
history_dict.append(entry)
# Fallback: load from DB if in-memory renders empty. Display only —
@@ -370,9 +387,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
db_msg.content = content
meta = {}
if db_msg.meta_data:
try: meta = json.loads(db_msg.meta_data)
except (json.JSONDecodeError, ValueError): pass
meta = _metadata_dict(db_msg.meta_data)
meta = dict(meta)
meta['edited'] = True
db_msg.meta_data = json.dumps(meta)
@@ -412,13 +428,13 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \
(isinstance(msg, dict) and msg.get('role') == 'assistant'):
if isinstance(msg, ChatMessage):
if not msg.metadata:
if not isinstance(msg.metadata, dict):
msg.metadata = {}
msg.metadata['stopped'] = True
if not msg.metadata.get('model'):
msg.metadata['model'] = session.model
else:
if 'metadata' not in msg:
if not isinstance(msg.get('metadata'), dict):
msg['metadata'] = {}
msg['metadata']['stopped'] = True
if not msg['metadata'].get('model'):
@@ -436,11 +452,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
)
if db_messages:
meta = {}
if db_messages.meta_data:
try:
meta = _json.loads(db_messages.meta_data)
except (json.JSONDecodeError, ValueError):
pass
meta = _metadata_dict(db_messages.meta_data)
meta = dict(meta)
meta['stopped'] = True
if not meta.get('model'):
meta['model'] = session.model
@@ -471,11 +484,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \
(isinstance(msg, dict) and msg.get('role') == 'assistant'):
if isinstance(msg, ChatMessage):
if not msg.metadata:
if not isinstance(msg.metadata, dict):
msg.metadata = {}
msg.metadata.update(meta_update)
else:
if 'metadata' not in msg:
if not isinstance(msg.get('metadata'), dict):
msg['metadata'] = {}
msg['metadata'].update(meta_update)
break
@@ -491,10 +504,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
.first()
)
if db_msg:
meta = {}
if db_msg.meta_data:
try: meta = _json.loads(db_msg.meta_data)
except (json.JSONDecodeError, ValueError): pass
meta = dict(_metadata_dict(db_msg.meta_data))
meta.update(meta_update)
db_msg.meta_data = _json.dumps(meta)
db.commit()
@@ -536,8 +546,12 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
merged_content = content1 + separator + content2
# Merge metadata
meta1 = (msg1.metadata if isinstance(msg1, ChatMessage) else msg1.get('metadata')) or {}
meta2 = (msg2.metadata if isinstance(msg2, ChatMessage) else msg2.get('metadata')) or {}
meta1 = dict(_metadata_dict(
msg1.metadata if isinstance(msg1, ChatMessage) else msg1.get('metadata')
))
meta2 = dict(_metadata_dict(
msg2.metadata if isinstance(msg2, ChatMessage) else msg2.get('metadata')
))
merged_meta = {**meta1, **meta2}
merged_meta.pop('stopped', None) # no longer stopped after continue
@@ -693,11 +707,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
pct = max(0.0, min(100.0, pct))
visible_messages = sum(
1 for m in session.history
if not (getattr(m, "metadata", None) or {}).get("hidden")
if not _metadata_dict(getattr(m, "metadata", None)).get("hidden")
)
compacted_messages = sum(
1 for m in session.history
if (getattr(m, "metadata", None) or {}).get("compacted")
if _metadata_dict(getattr(m, "metadata", None)).get("compacted")
)
can_compact = used > 0
return {
+19 -6
View File
@@ -5,10 +5,11 @@ import shlex
import subprocess
from copy import deepcopy
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from core.platform_compat import run_ssh_command
from routes._validators import validate_remote_host, validate_ssh_port
from src.auth_helpers import require_non_bearer_request
# Backends the manual hardware simulator accepts. Must stay a subset of what
@@ -180,24 +181,32 @@ def _inspect_model_path(model_path: str, host: str = "", ssh_port: str = "") ->
def setup_hwfit_routes():
router = APIRouter(prefix="/api/hwfit", tags=["hwfit"])
router = APIRouter(
prefix="/api/hwfit",
tags=["hwfit"],
dependencies=[Depends(require_non_bearer_request)],
)
@router.get("/system")
def get_system(host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False):
def get_system(host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, request: Request = None):
"""Detect and return current system hardware info. Pass host=user@server for remote.
fresh=true bypasses the per-host cache (the Rescan button)."""
if request is not None:
require_non_bearer_request(request)
from services.hwfit.hardware import detect_system
host, ssh_port = _validate_detection_target(host, ssh_port)
return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
@router.get("/models")
def get_models(use_case: str = "", sort: str = "newest", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, refresh_catalog: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False):
def get_models(use_case: str = "", sort: str = "newest", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, refresh_catalog: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False, request: Request = None):
"""Rank LLM models against detected hardware and return scored results.
gpu_count: override GPU count (0 = CPU only, 1-N = simulate N GPUs of the
active group). gpu_group: index into system.gpu_groups (the homogeneous
pools) to target — empty/auto = the largest pool. vLLM can only
tensor-parallel across identical GPUs, so we never mix pools.
fresh=true bypasses the hardware-detection cache."""
if request is not None:
require_non_bearer_request(request)
from services.hwfit.hardware import detect_system
from services.hwfit.fit import rank_models
from services.hwfit.models import get_models, model_catalog_path, refresh_dynamic_catalogs
@@ -316,7 +325,7 @@ def setup_hwfit_routes():
return payload
@router.get("/profiles")
def get_serve_profiles(model: str = "", model_path: str = "", host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, serve_weights_gb: float = 0.0, serve_quant: str = ""):
def get_serve_profiles(model: str = "", model_path: str = "", host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, serve_weights_gb: float = 0.0, serve_quant: str = "", request: Request = None):
"""Compute llama.cpp serve profiles (Quality/Balanced/Speed) for `model`
against the detected hardware on `host` (or local). Returns concrete
flags (n_gpu_layers, n_cpu_moe, cache_type, ctx) the serve UI can apply.
@@ -325,6 +334,8 @@ def setup_hwfit_routes():
catalog (e.g. an ad-hoc HF repo), pass enough hints via a minimal synthetic
entry isn't possible here, so we return [] and the UI keeps manual flags.
"""
if request is not None:
require_non_bearer_request(request)
from services.hwfit.hardware import detect_system
from services.hwfit.models import get_models
from services.hwfit.profiles import compute_serve_profiles
@@ -410,8 +421,10 @@ def setup_hwfit_routes():
}
@router.get("/image-models")
def get_image_models(sort: str = "fit", search: str = "", host: str = "", gpu_count: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False):
def get_image_models(sort: str = "fit", search: str = "", host: str = "", gpu_count: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, request: Request = None):
"""Rank image generation models against detected hardware."""
if request is not None:
require_non_bearer_request(request)
from services.hwfit.hardware import detect_system
from services.hwfit.image_models import rank_image_models
host, ssh_port = _validate_detection_target(host, ssh_port)
+12 -2
View File
@@ -1,5 +1,5 @@
# routes/memory_routes.py
from fastapi import APIRouter, Form, HTTPException, Request, UploadFile, File
from fastapi import APIRouter, Depends, Form, HTTPException, Request, UploadFile, File
from typing import Dict, Any, Optional, List
import json
import os
@@ -53,9 +53,19 @@ def _load_for_update(memory_manager) -> List[Dict[str, Any]]:
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
"""Set up memory-related routes."""
router = APIRouter(prefix="/api/memory", tags=["memory"])
router = APIRouter(
prefix="/api/memory",
tags=["memory"],
dependencies=[Depends(require_user)],
)
def _owner(request: Request) -> Optional[str]:
# Router dependencies do not run when a handler is called directly
# (including through an integration router), so keep the same bearer
# rejection at the owner-resolution seam. ``None`` is retained only
# for legacy unit callers; real ASGI requests always carry Request.
if request is not None:
require_user(request)
return get_current_user(request)
def _assert_session_owner(session_obj, user):
+41 -10
View File
@@ -29,7 +29,12 @@ from src.endpoint_resolver import (
build_models_url,
build_headers,
)
from src.auth_helpers import _auth_disabled, owner_filter, require_chat_scope
from src.auth_helpers import (
_auth_disabled,
is_bearer_principal,
owner_filter,
require_chat_scope,
)
logger = logging.getLogger(__name__)
@@ -1536,7 +1541,12 @@ def setup_model_routes(model_discovery):
_refresh_inflight["v"] = False
threading.Thread(target=_do, daemon=True).start()
def _fetch_models(owner: str = "", is_admin: bool = False):
def _fetch_models(
owner: str = "",
is_admin: bool = False,
*,
read_only: bool = False,
):
"""Return model list from cached data (instant). Background refresh keeps caches fresh.
SECURITY: filters endpoints by `owner` — without this the picker
@@ -1551,7 +1561,7 @@ def setup_model_routes(model_discovery):
db = SessionLocal()
try:
if _disable_stale_cookbook_local_endpoints(db):
if not read_only and _disable_stale_cookbook_local_endpoints(db):
_invalidate_models_cache()
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
if owner and not is_admin:
@@ -1634,6 +1644,17 @@ def setup_model_routes(model_discovery):
except Exception as e:
logger.error("Auth gate error in GET /api/models, failing closed: %s", e)
raise HTTPException(status_code=500, detail="Internal error")
bearer = is_bearer_principal(request)
if bearer and (refresh or background):
raise HTTPException(
403,
"API tokens may only read the owner-scoped cached model list",
)
if bearer:
# The bearer-compatible path is deliberately read-only: no global
# admin view, stale-row cleanup, process cache writes, background
# probes, stored endpoint credentials, or refresh state changes.
return _fetch_models(owner=owner, is_admin=False, read_only=True)
# Admins see every endpoint (they manage the global pool); regular
# users get the owner-scoped view.
_is_admin = False
@@ -2413,11 +2434,11 @@ def setup_model_routes(model_discovery):
# no per-user default yet, we resolve via the owner-scoped endpoint
# lookup below (last-resort: first enabled endpoint THIS user owns).
# Unauthenticated single-user mode keeps the old behavior.
from src.auth_helpers import get_current_user as _gcu
try:
_user = _gcu(request) or ""
except Exception:
_user = ""
# Resolve through the same owner/scope gate as the model picker. In an
# auth-disabled process there is no middleware to stamp token state, so
# raw bearer detection must still prevent a token from resolving
# global/admin defaults.
_user = require_chat_scope(request) or ""
# Admins resolve via the global defaults (they own them, and the
# scoped resolution was making the picker disappear for them).
# Regular users get per-user prefs with NO global fallback for the
@@ -2427,7 +2448,12 @@ def setup_model_routes(model_discovery):
_is_admin = False
try:
auth_mgr = getattr(request.app.state, "auth_manager", None)
if _user and auth_mgr is not None and getattr(auth_mgr, "is_admin", None):
if (
_user
and not is_bearer_principal(request)
and auth_mgr is not None
and getattr(auth_mgr, "is_admin", None)
):
_is_admin = bool(auth_mgr.is_admin(_user))
except Exception:
_is_admin = False
@@ -2686,8 +2712,13 @@ def setup_model_routes(model_discovery):
# ── Tool management ──
@router.get("/tools")
def list_tools():
def list_tools(request: Request):
"""List all available tools with their enabled/disabled status."""
# Tool inventory is an interactive/agent capability description, not
# part of the narrow bearer chat contract. Cookie/local callers retain
# the historical response.
from src.auth_helpers import require_non_bearer_request
require_non_bearer_request(request)
from src.agent_tools import TOOL_TAGS
settings = _load_settings()
disabled = set(settings.get("disabled_tools", []))
+6
View File
@@ -16,6 +16,7 @@ from pathlib import Path
from typing import Dict, Any
from core.platform_compat import IS_APPLE_SILICON, which_tool
from core.middleware import INTERNAL_TOOL_USER
from src.auth_helpers import is_bearer_principal
from src.host_docker_access import (
HOST_DOCKER_ACCESS_HINT,
host_docker_access_enabled as _host_docker_access_enabled,
@@ -53,6 +54,11 @@ from core.platform_compat import (
def _require_admin(request: Request):
"""Reject non-admin callers. Shell exec is admin-only — never expose to
regular users; that's RCE-after-signup."""
# This route predates the shared middleware helper and is also called
# directly by a few integration paths. Reject the credential class before
# trusting a caller-supplied current_user that might look administrative.
if is_bearer_principal(request):
raise HTTPException(403, "API tokens cannot use admin host-control surfaces")
auth_manager = getattr(request.app.state, "auth_manager", None)
if not auth_manager:
# No auth at all — only safe in fully-trusted localhost dev mode
+3 -1
View File
@@ -2,7 +2,7 @@
import os
from fastapi import APIRouter, Request, HTTPException, Query
from src.auth_helpers import get_current_user
from src.auth_helpers import get_current_user, require_non_bearer_request
from src.tool_security import owner_is_admin_or_single_user
# Cap entries returned per directory (mirrors filesystem_tools._CODENAV_MAX_HITS).
@@ -24,6 +24,7 @@ def setup_workspace_routes():
NON_ADMIN_BLOCKED_TOOLS). A non-admin who can't use those tools must not
be able to map the host's directory tree either.
"""
require_non_bearer_request(request)
owner = get_current_user(request)
if not owner_is_admin_or_single_user(owner):
raise HTTPException(status_code=403, detail="Workspace browsing is admin-only")
@@ -75,6 +76,7 @@ def setup_workspace_routes():
instead of being stored client-side and silently dropped at chat time.
Admin-gated like /browse: it confirms path existence on the host.
"""
require_non_bearer_request(request)
owner = get_current_user(request)
if not owner_is_admin_or_single_user(owner):
raise HTTPException(status_code=403, detail="Workspace selection is admin-only")