Compare commits

...
17 Commits
Author SHA1 Message Date
pewdiepie-archdaemon 7b9ef95b60 Stabilize auth session revocation tests 2026-06-02 06:02:49 +09:00
LohinthandLohinth a8d9a180d9 Scope document tools to caller owner
Co-authored-by: Lohinth <lohinth25@proton.me>
2026-06-02 06:00:02 +09:00
Ernest Hysa d42e6a7acc Scope skill mutations to caller owner
SkillsManager.update_skill walks every SKILL.md on disk and matches by
slug only; the 'owner' key in its scalar_keys whitelist meant a caller
could pass updates={'owner': 'attacker', 'description': 'pwned'} and the
first matching file on disk got silently re-owned. Two users with the
same slug under different category directories (which is supported by
the on-disk layout <category>/<name>/SKILL.md) could each stomp the
other's skill via the manage_skills tool or the in-process callers in
tool_implementations.py (edit, patch, publish, delete).

update_skill and delete_skill now require the caller's owner and only
match a file whose parsed owner field matches. The default of None
means 'no scope' and only matches ownerless skills, so an unsafe call
without an explicit owner is now a no-op. 'owner' is also removed from
scalar_keys so the updates dict cannot be used to reassign ownership
even when the manager is called from an in-process path that didn't
supply the owner argument.

The in-process callers in tool_implementations.py are updated to pass
owner=owner (which was already in scope at every call site) so the
HTTP and agent paths both go through the scoped check. The HTTP route
at routes/skills_routes.py:1499 was already owner-scoped via
sm.load(owner=user); the fix brings the in-process path up to the
same standard.
2026-06-02 05:59:43 +09:00
Alexandre Teixeira 5dd5847d4b Revoke stale sessions after password change
After a successful password change, revoke all browser sessions for the
same user except the one that submitted the request. This prevents stale
sessions on other devices from remaining valid after credentials are
updated.

Keep API-token behavior unchanged. The current browser session is
preserved so the user can continue from the tab that changed the
password.

Add focused regression tests for preserving the current session, revoking
other sessions, persisting revocation, and avoiding revocation when the
current password is incorrect.
2026-06-02 05:59:22 +09:00
SurprisedDuckandClaude 7d10fb6260 Reserve internal sentinel usernames
`core.middleware.require_admin` grants admin to any request whose
`request.state.current_user == "internal-tool"` — the sentinel meant only
for the in-process tool-loopback path. But the normal cookie auth path
(app.py) sets `current_user` to the raw username, and neither `create_user`
nor the signup route reserved that name. As a result an account literally
named "internal-tool" was silently treated as admin by every
`require_admin`-gated route. With self-service signup enabled this is an
anonymous -> admin privilege escalation.

Reserve the full synthetic-owner set the codebase already special-cases —
"internal-tool", "api", "demo", "system" (see `_SYNTHETIC_OWNERS` in
routes/assistant_routes.py and the matching guards in src/task_scheduler.py
and routes/research_routes.py). "api" collides with the bearer-token owner
sentinel; "demo"/"system" would leave a real account denied an assistant
and inconsistently owner-scoped.

Refuse to create or rename into any reserved name (case/space-normalized),
and reject empty usernames while we're here. Adds a regression test.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 05:58:58 +09:00
SurprisedDuckandClaude b70ae56ffa Sanitize preserved markdown HTML
`mdToHtml` deliberately stashes literal <details> blocks and <a> tags from
the source text *before* the global HTML-escape pass and restores them
verbatim into the string callers assign to `innerHTML` (e.g. chatRenderer's
`b.innerHTML = ...processWithThinking(text)`). Nothing scrubbed those
fragments, so message/agent content containing
`<details><img src=x onerror=...></details>` or
`<a href="javascript:..." onmouseover=...>` executed arbitrary script in
the authenticated page.

Route both stashed fragments through `sanitizeAllowedHtml()`, which parses
them in an inert <template> (no resource loads, no script execution),
removes script-capable elements, and strips event-handler attributes plus
javascript:/vbscript:/data: URL schemes. Hardening details:

- Compare tag names case-insensitively and drop the SVG/MathML foreign-
  content roots. An SVG-namespaced <script> has the lower-case tagName
  'script', so an HTML-only upper-case check would miss it — a real bypass.
- Sanitize to a fixpoint (re-parse + re-clean until stable) to blunt
  mutation-XSS, where re-serializing/re-parsing reshapes the tree.

Benign anchors and <details> blocks are preserved unchanged.

Verified under jsdom against the obvious vectors plus mutation-XSS probes
(svg/math-namespaced <script>, foreignObject, ns-confusion, comment
breakout, template smuggling): no script/iframe element, event handler, or
javascript:/data: URL survives, and benign markup is kept.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 05:58:38 +09:00
ghreprimandandghreprimand 491a8a5480 Harden backup restore tar extraction
Co-authored-by: ghreprimand <203024559+ghreprimand@users.noreply.github.com>
2026-06-02 05:55:03 +09:00
Alexandre Teixeira 26483661da Restrict provider discovery to admins
Require admin access before serving provider discovery data from
GET /api/providers. This prevents normal authenticated users from
triggering provider discovery or receiving cached provider host data.

Keep GET /api/models available to normal users and leave the existing
admin-only GET /api/discover behavior unchanged.

Add a focused regression test to ensure unauthorized callers cannot
trigger discovery and cannot receive cached provider data.
2026-06-02 05:54:40 +09:00
SurprisedDuck 7268c49992 Make LLM host health maps thread-safe
The synchronous llm_call() runs in FastAPI's threadpool (sync route
handlers such as POST /sessions/auto-sort), while llm_call_async() runs
on the event loop. Both mutate the module-level _response_cache,
_host_fails and _dead_hosts dicts, so these are touched from multiple OS
threads concurrently. Two races result:

- _set_cached_response() snapshots 64 keys then deletes them with
  `del _response_cache[key]`; if another thread evicts the same key
  first, the del raises KeyError mid-eviction. Switched to
  pop(key, None).
- _mark_host_dead() does get()+1+set() on _host_fails with no lock, so
  concurrent connect failures lose increments and a genuinely dead host
  can stay under its cooldown threshold. Guarded the host-health maps
  with a threading.Lock (also applied to _is_host_dead / _clear_host_dead
  for consistent reads).

Adds tests/test_llm_core_concurrency.py with deterministic regression
tests (phantom snapshot key for the eviction race; a slow-read dict that
forces the lost-update window for the counter). Both fail on the
unpatched code and pass with the fix.
2026-06-02 05:54:23 +09:00
ooovenenosoandKevin cd6041477c Refresh local model context after restart
Co-authored-by: Kevin <120500656+oooindefatigable@users.noreply.github.com>
2026-06-02 05:54:06 +09:00
Prakhya a96593a99b Improve Ollama endpoint error messages 2026-06-02 05:53:50 +09:00
SurprisedDuckandClaude 7a830e504d Escape email fold summary metadata
The email reader folds quoted history into <details> summaries via
`_foldSummary()` (static/js/emailLibrary/signatureFold.js), which builds a
sender/date "meta" chip into the summary HTML and assigns it to innerHTML.
The server-side thread parser (`_extract_quote_meta`,
src/email_thread_parser.py) strips tags but then un-escapes HTML entities
and preserves `<...>` patterns, and that raw meta reaches `_foldSummary`
unescaped via `_renderTurnsFromServer` (`t.meta`) — so an inbound email
whose quoted attribution contains `From: &lt;img src=x onerror=...&gt;`
runs script when the victim merely opens the message (stored XSS).

Make `_foldSummary` the single escaping chokepoint: escape `primary` and
`subMeta` with the module's existing `_esc`. The client-side
`_extractQuoteMeta` previously pre-escaped its output, and every consumer
of it routes through `_foldSummary`, so drop that now-redundant escaping to
avoid double-encoding (e.g. "Ben & Jerry" -> "Ben &amp;amp; Jerry").

Verified (jsdom): server-raw and client-extracted malicious metas yield 0
live elements and 0 event-handler attributes; benign "Ben & Jerry" renders
single-escaped.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 05:50:53 +09:00
Yatsuiii 63d93ff211 Normalize stored usernames on auth load
verify_password() and create_session() both call .strip().lower() on
the incoming username, but _load() stored keys verbatim from auth.json.
Any mixed-case key (e.g. written by manual edit or a future migration)
would never match, producing a permanent 'Invalid credentials' error.

Fix: lowercase all keys at load time so the in-memory dict always
matches what the login path expects.

Fixes #423
2026-06-02 05:50:36 +09:00
Afonso Coutinho 5da662441c Validate slash command time minutes
* fix: reject hour > 23 in 'today/tomorrow' reminder time parsing

* fix: reject minute > 59 in reminder time parsing
2026-06-02 05:50:19 +09:00
Elle d885c70462 Treat Docker host gateway as local
When running Odysseus in Docker and connecting to a local LLM on the host machine (e.g. `llama.cpp` or `Ollama`), the standard endpoint `http://host.docker.internal` is used to breach the container network. 

Because `host.docker.internal` was missing from `_LOCAL_HOSTS`, Odysseus incorrectly treated local self-hosted models as cloud APIs. This triggered the fallback behavior where actual API-reported context limits were being ignored and overridden by hardcoded fallbacks in `KNOWN_CONTEXT_WINDOWS`.

**Changes**
- Added `"host.docker.internal"` to the `_LOCAL_HOSTS` whitelist in `src/model_context.py` so that Dockerized deployments correctly trust and respect the context limits of locally hosted models.

**Checks Ran**
- [x] Syntax check (`python -m py_compile src/model_context.py`)
- [x] Tested manually in Docker (`docker compose up -d --build`) on a Windows host using `llama-server`. The correct API context length is now correctly reported in the UI instead of falling back to the 131k hardcode.
2026-06-02 05:49:59 +09:00
3ef88fc7ff Recognize Gemma as tool-capable
Gemma models (gemma-2/3/4) support OpenAI-style function calling, but
"gemma" was missing from the _model_supports_tools heuristic in
stream_agent_loop(). On a non-allowlisted endpoint (e.g. a self-hosted
OpenAI-compatible server), a Gemma-backed agent therefore never receives
native tool schemas and falls back to the prompt-text tool-call
convention — which Gemma does not follow. The result is that tool calls
are emitted as raw text and never execute.

Add "gemma" to the capability keyword list alongside the other
tool-capable families.

Co-authored-by: 2revoemag <2revoemag@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 05:49:43 +09:00
pewdiepie-archdaemon e03491664a Stabilize security regression tests 2026-06-02 05:48:59 +09:00
24 changed files with 1184 additions and 120 deletions
+49
View File
@@ -40,6 +40,22 @@ DEFAULT_AUTH_PATH = os.path.join(
)
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
# Usernames the auth + middleware layer reserve as internal "synthetic owner"
# sentinels; they must never belong to a real account. The most dangerous is
# "internal-tool": `core.middleware.require_admin` treats any request whose
# `current_user == "internal-tool"` as the in-process tool loopback and grants
# admin, and because the cookie auth path sets `current_user` to the raw
# username, an account literally named "internal-tool" would be silently
# treated as an admin by every `require_admin`-gated route. "api" collides with
# the bearer-token owner-attribution sentinel. "demo"/"system" round out the
# synthetic-owner set the rest of the codebase already special-cases (see
# `_SYNTHETIC_OWNERS` in routes/assistant_routes.py and the matching guards in
# src/task_scheduler.py / routes/research_routes.py) — a real account with one
# of those names would be denied an assistant and inconsistently owner-scoped.
# Refuse to create or rename into any of them so the sentinels can't be
# impersonated. (Keep this in sync with that synthetic-owner set.)
RESERVED_USERNAMES = frozenset({"internal-tool", "api", "demo", "system"})
def _hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
@@ -73,6 +89,15 @@ class AuthManager:
if os.path.exists(self.auth_path):
with open(self.auth_path, "r", encoding="utf-8") as f:
self._config = json.load(f)
# Normalize all stored usernames to lowercase so they match
# the .strip().lower() applied at login/verify time. Fixes
# "Invalid credentials" when auth.json was written with
# mixed-case keys (e.g. via manual edit or a future migration).
if "users" in self._config:
self._config["users"] = {
k.strip().lower(): v
for k, v in self._config["users"].items()
}
logger.info("Auth config loaded")
else:
self._config = {}
@@ -168,6 +193,11 @@ class AuthManager:
def create_user(self, username: str, password: str, is_admin: bool = False) -> bool:
"""Create a new user account."""
username = username.strip().lower()
if not username:
return False
if username in RESERVED_USERNAMES:
logger.warning("Refused to create reserved username '%s'", username)
return False
if username in self.users:
return False
if "users" not in self._config:
@@ -221,6 +251,9 @@ class AuthManager:
requesting_user = (requesting_user or "").strip().lower()
if not old_username or not new_username:
return False
if new_username in RESERVED_USERNAMES:
logger.warning("Refused to rename '%s' into reserved username '%s'", old_username, new_username)
return False
if old_username not in self.users:
return False
if new_username in self.users:
@@ -446,6 +479,22 @@ class AuthManager:
self._sessions.pop(token, None)
self._save_sessions()
def revoke_user_sessions(self, username: str, except_token: Optional[str] = None) -> int:
"""Revoke active browser sessions for a user, optionally preserving one."""
username = username.strip().lower()
revoked = 0
with self._sessions_lock:
to_drop = [
token for token, session in self._sessions.items()
if token != except_token and (session or {}).get("username") == username
]
for token in to_drop:
self._sessions.pop(token, None)
revoked += 1
if revoked:
self._save_sessions()
return revoked
def status(self, token: Optional[str]) -> Dict[str, Any]:
username = self.get_username_for_token(token)
authenticated = username is not None
+2
View File
@@ -178,9 +178,11 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(401, "Not authenticated")
if len(body.new_password) < 8:
raise HTTPException(400, "Password must be at least 8 characters")
current_token = request.cookies.get(SESSION_COOKIE)
ok = await asyncio.to_thread(auth_manager.change_password, user, body.current_password, body.new_password)
if not ok:
raise HTTPException(400, "Current password is incorrect")
await asyncio.to_thread(auth_manager.revoke_user_sessions, user, current_token)
return {"ok": True}
# ------------------------------------------------------------------
+28 -2
View File
@@ -424,6 +424,31 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
return {"reachable": False, "status_code": None, "error": last_error}
def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str:
"""Return a provider-aware error message for failed endpoint probes."""
ping = ping or {}
error = ping.get("error")
parsed = urlparse(base_url)
host = (parsed.hostname or "").lower()
is_ollama = parsed.port == 11434 or "ollama" in host or "ollama" in base_url.lower()
if is_ollama:
parts = ["No Ollama models found for that endpoint."]
if error:
parts.append(f"Last probe error: {error}.")
parts.append("Check that Ollama is running and that the base URL is correct.")
parts.append("For native/local installs, use http://localhost:11434/v1.")
parts.append("For Docker, use http://host.docker.internal:11434/v1 when Ollama runs on the host.")
parts.append("Run `ollama list` to confirm at least one model is installed.")
return " ".join(parts)
if error:
return f"No models found for that provider/key. Last probe error: {error}."
return "No models found for that provider/key."
def setup_model_routes(model_discovery):
router = APIRouter(prefix="/api")
@@ -865,8 +890,9 @@ def setup_model_routes(model_discovery):
_PROVIDERS_CACHE_TTL = 30 # seconds
@router.get("/providers")
def providers(refresh: bool = False):
def providers(request: Request, refresh: bool = False):
"""Get all available providers (cached for 30s)."""
require_admin(request)
now = _time.time()
if not refresh and _providers_cache["data"] is not None and (now - _providers_cache["time"]) < _PROVIDERS_CACHE_TTL:
return _providers_cache["data"]
@@ -999,7 +1025,7 @@ def setup_model_routes(model_discovery):
if should_probe and not model_ids:
ping = _ping_endpoint(base_url, api_key.strip() or None, timeout=_probe_timeout)
if require_model_list and not model_ids:
raise HTTPException(400, "No models found for that provider/key")
raise HTTPException(400, _model_endpoint_error_message(base_url, ping))
ep_id = str(uuid.uuid4())[:8]
db = SessionLocal()
+43 -14
View File
@@ -24,9 +24,9 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib"))
from cli import quiet_logs, emit, fail, common_parser, run, REPO_ROOT as _REPO_ROOT
quiet_logs()
import argparse, json, logging, os, sqlite3, subprocess, sys, tarfile, tempfile
import argparse, json, logging, os, shutil, sqlite3, subprocess, sys, tarfile, tempfile
from datetime import datetime
from pathlib import Path
from pathlib import Path, PurePosixPath
_DATA_DIR = _REPO_ROOT / "data"
_BACKUP_DIR = _REPO_ROOT / "backups"
@@ -70,7 +70,7 @@ def cmd_snapshot(args):
)
out_path.parent.mkdir(parents=True, exist_ok=True)
sqlite_dbs = [p for p in _DATA_DIR.rglob("*.db") if p.is_file()]
sqlite_dbs = [p for p in _DATA_DIR.rglob("*.db") if p.is_file() and not p.is_symlink()]
files_added = 0
total_bytes = 0
@@ -87,7 +87,7 @@ def cmd_snapshot(args):
with tarfile.open(out_path, "w:gz") as tar:
for p in sorted(_DATA_DIR.rglob("*")):
if not p.is_file():
if not p.is_file() or p.is_symlink():
continue
rel = p.relative_to(_DATA_DIR.parent)
# Skip user-asked-to-skip categories
@@ -143,6 +143,7 @@ def cmd_verify(args):
try:
with tarfile.open(path, "r:gz") as tar:
members = tar.getmembers()
_validate_restore_members(members)
except (tarfile.TarError, OSError) as e:
fail(f"tarball is corrupt: {e}")
emit({
@@ -154,6 +155,35 @@ def cmd_verify(args):
}, args)
def _validate_restore_members(members):
"""Reject archive entries that can escape data/ during restore."""
for m in members:
rel = PurePosixPath(m.name)
if rel.is_absolute() or ".." in rel.parts:
fail(f"refusing tarball with absolute/parent path: {m.name!r}")
if not rel.parts or rel.parts[0] != "data":
fail(f"refusing tarball with entry outside data/: {m.name!r}")
if m.issym() or m.islnk():
fail(f"refusing tarball with link entry: {m.name!r}")
if not (m.isdir() or m.isfile()):
fail(f"refusing tarball with special file entry: {m.name!r}")
def _extract_restore_members(tar, members, root: Path) -> None:
"""Extract only regular files/directories after validation."""
for m in members:
target = root.joinpath(*PurePosixPath(m.name).parts)
if m.isdir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
src = tar.extractfile(m)
if src is None:
fail(f"extract failed: could not read {m.name!r}")
with src, open(target, "wb") as dst:
shutil.copyfileobj(src, dst)
def cmd_restore(args):
"""Overwrite `data/` from a tarball. Destructive; requires --yes."""
path = Path(args.path)
@@ -161,26 +191,25 @@ def cmd_restore(args):
fail(f"no file at {path}")
if not args.yes:
fail("restore is destructive — pass --yes to confirm overwriting data/")
# Sanity check: tarball entries must all be under `data/`. If anyone
# crafted a malicious tarball with `../etc/passwd`, refuse.
# Sanity check: tarball entries must all be safe, regular files/dirs under
# `data/`. Avoid extractall() so symlink/hardlink entries can't redirect a
# later write outside the repo.
stash = None
with tarfile.open(path, "r:gz") as tar:
for m in tar.getmembers():
if m.name.startswith("/") or ".." in Path(m.name).parts:
fail(f"refusing tarball with absolute/parent path: {m.name!r}")
if not m.name.startswith("data/") and m.name != "data":
fail(f"refusing tarball with entry outside data/: {m.name!r}")
members = tar.getmembers()
_validate_restore_members(members)
# Save a safety copy of current data/ before extracting.
if _DATA_DIR.exists():
if _DATA_DIR.exists() or _DATA_DIR.is_symlink():
stash = _REPO_ROOT / f"data.before-restore-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
os.rename(_DATA_DIR, stash)
try:
tar.extractall(path=_REPO_ROOT)
_extract_restore_members(tar, members, _REPO_ROOT)
except Exception as e:
fail(f"extract failed: {e}")
emit({
"ok": True,
"restored_from": str(path),
"previous_data_stashed_at": str(stash) if _DATA_DIR.exists() else None,
"previous_data_stashed_at": str(stash) if stash else None,
}, args)
+21 -5
View File
@@ -363,19 +363,33 @@ class SkillsManager:
return sk.to_dict()
def update_skill(self, skill_id: str, updates: Dict) -> bool:
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
"""`skill_id` is the slug name. Allows updating any field plus
renames if `name` changes (file is moved on disk)."""
renames if `name` changes (file is moved on disk).
The call is owner-scoped: it matches a skill on disk only if
`skill.owner == owner` (string compare; both empty-string and
None mean "ownerless"). When `owner is None` (the default), the
call only matches skills whose own `owner` field is empty —
callers that want to edit an owned skill must pass the matching
owner explicitly. This prevents a caller with one owner from
mutating a file owned by another user that happens to share
the same slug across category directories. The `owner` key in
`updates` is also ignored — ownership is not an editable field
via this path; rename or admin tooling is required for that.
"""
for path in self._iter_skill_files():
sk = self._read_skill(path)
if not sk or sk.name != skill_id:
continue
if (sk.owner or "") != (owner or ""):
continue
old_dir = os.path.dirname(path)
# Apply updates in a Skill-shape friendly way
scalar_keys = (
"description", "version", "category", "status", "confidence",
"source", "teacher_model", "owner", "when_to_use",
"source", "teacher_model", "when_to_use",
"body_extra",
)
for k in scalar_keys:
@@ -421,11 +435,13 @@ class SkillsManager:
return True
return False
def delete_skill(self, skill_id: str) -> bool:
def delete_skill(self, skill_id: str, owner: Optional[str] = None) -> bool:
for path in self._iter_skill_files():
sk = self._read_skill(path)
if not sk or sk.name != skill_id:
continue
if (sk.owner or "") != (owner or ""):
continue
skill_dir = os.path.dirname(path)
try:
# Remove the whole skill dir
+1 -1
View File
@@ -1358,7 +1358,7 @@ async def stream_agent_loop(
except Exception as _e:
logger.debug(f"endpoint supports_tools lookup failed: {_e}")
_model_supports_tools = any(kw in _model_lc for kw in (
"deepseek", "gpt-4", "gpt-5", "gpt-o", "claude", "gemini",
"deepseek", "gpt-4", "gpt-5", "gpt-o", "claude", "gemini", "gemma",
"qwen3", "qwen2.5", "mixtral", "mistral", "llama-3.1", "llama-3.2",
"llama-3.3", "llama-4",
# Local-served models that follow OpenAI-style function calling
+29 -16
View File
@@ -5,6 +5,7 @@ import time
import json
import logging
import hashlib
import threading
from fastapi import HTTPException
from typing import Optional, Dict, List
from urllib.parse import urlparse
@@ -56,6 +57,12 @@ DEAD_HOST_COOLDOWN = 20.0
_HOST_FAIL_THRESHOLD = 2
_dead_hosts: Dict[str, float] = {}
_host_fails: Dict[str, int] = {}
# Guards the two maps above. The synchronous llm_call() runs inside FastAPI's
# threadpool (sync routes such as /sessions/auto-sort) while llm_call_async()
# runs on the event loop, so these maps are mutated from multiple OS threads.
# Without the lock the get()+1+set on _host_fails is a read-modify-write that
# loses failure counts under concurrent connect errors (issue #659).
_host_health_lock = threading.Lock()
_model_activity: Dict[str, float] = {}
def _model_activity_key(url: str, model: str) -> str:
@@ -81,13 +88,14 @@ def _host_key(url: str) -> str:
def _is_host_dead(url: str) -> bool:
key = _host_key(url)
exp = _dead_hosts.get(key)
if exp is None:
return False
if time.time() >= exp:
_dead_hosts.pop(key, None)
return False
return True
with _host_health_lock:
exp = _dead_hosts.get(key)
if exp is None:
return False
if time.time() >= exp:
_dead_hosts.pop(key, None)
return False
return True
def _mark_host_dead(url: str) -> bool:
"""Record a connect failure. Only actually cools the host after
@@ -95,17 +103,19 @@ def _mark_host_dead(url: str) -> bool:
is now cooled (so callers can log accurately), False if it's still
within its allowed-failure grace."""
key = _host_key(url)
n = _host_fails.get(key, 0) + 1
_host_fails[key] = n
if n >= _HOST_FAIL_THRESHOLD:
_dead_hosts[key] = time.time() + DEAD_HOST_COOLDOWN
return True
return False
with _host_health_lock:
n = _host_fails.get(key, 0) + 1
_host_fails[key] = n
if n >= _HOST_FAIL_THRESHOLD:
_dead_hosts[key] = time.time() + DEAD_HOST_COOLDOWN
return True
return False
def _clear_host_dead(url: str) -> None:
key = _host_key(url)
_dead_hosts.pop(key, None)
_host_fails.pop(key, None)
with _host_health_lock:
_dead_hosts.pop(key, None)
_host_fails.pop(key, None)
# Shared async HTTP client. Reusing one client keeps connections warm:
@@ -130,7 +140,10 @@ def _set_cached_response(cache_key: str, response: str) -> None:
if len(_response_cache) > 128:
keys_to_remove = list(_response_cache.keys())[:64]
for key in keys_to_remove:
del _response_cache[key]
# pop(), not del: another thread (sync llm_call runs in FastAPI's
# threadpool) may have already evicted the same snapshotted key,
# and del would raise KeyError mid-eviction (issue #659).
_response_cache.pop(key, None)
_response_cache[cache_key] = response
# ── Anthropic native API adapter ──
+7 -4
View File
@@ -14,7 +14,7 @@ import httpx
logger = logging.getLogger(__name__)
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1"}
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal"}
_PRIVATE_PREFIXES = ("10.", "172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
"172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
@@ -169,12 +169,15 @@ def get_context_length(endpoint_url: str, model: str) -> int:
or context_window fields. Caches result per model ID.
Falls back to DEFAULT_CONTEXT if unavailable.
"""
if model in _context_cache:
is_local = _is_local_endpoint(endpoint_url)
if not is_local and model in _context_cache:
return _context_cache[model]
ctx = _query_context_length(endpoint_url, model)
# Only cache non-default values to allow retry on next request
if ctx != DEFAULT_CONTEXT:
# Only cache non-default values to allow retry on next request.
# Local endpoints can restart with a different --max-model-len while keeping
# the same model id, so always re-query them instead of serving stale cache.
if not is_local and ctx != DEFAULT_CONTEXT:
_context_cache[model] = ctx
logger.info(f"Context length for {model}: {ctx}")
return ctx
+4 -4
View File
@@ -651,15 +651,15 @@ async def execute_tool_block(
elif tool == "create_document":
title = content.split("\n")[0].strip()[:60]
desc = f"create_document: {title}"
result = await do_create_document(content, session_id=session_id)
result = await do_create_document(content, session_id=session_id, owner=owner)
elif tool == "update_document":
desc = f"update_document: {content.split(chr(10))[0][:60]}"
result = await do_update_document(content)
result = await do_update_document(content, owner=owner)
elif tool == "edit_document":
result = await do_edit_document(content)
result = await do_edit_document(content, owner=owner)
desc = f"edit_document: {result.get('title', '')}"
elif tool == "suggest_document":
result = await do_suggest_document(content)
result = await do_suggest_document(content, owner=owner)
desc = f"suggest_document: {result.get('count', 0)} suggestions"
elif tool == "search_chats":
query = content.split("\n")[0].strip()
+41 -16
View File
@@ -88,6 +88,28 @@ def get_active_document():
return _active_document_id
def _owned_document_query(query, Document, owner: Optional[str]):
if owner is None:
return query.filter(False)
return query.filter(Document.owner == owner)
def _get_owned_document(db, Document, doc_id: str, owner: Optional[str], active_only: bool = False):
q = db.query(Document).filter(Document.id == doc_id)
if active_only:
q = q.filter(Document.is_active == True)
q = _owned_document_query(q, Document, owner)
return q.first()
def _most_recent_owned_document(db, Document, owner: Optional[str], active_only: bool = False):
q = db.query(Document)
if active_only:
q = q.filter(Document.is_active == True)
q = _owned_document_query(q, Document, owner)
return q.order_by(Document.updated_at.desc()).first()
# ---------------------------------------------------------------------------
# Document tools — create/update/edit/suggest living documents
# ---------------------------------------------------------------------------
@@ -171,7 +193,7 @@ def _coerce_email_document_content(existing: str, incoming: str) -> str:
return header.rstrip() + "\n---\n" + body
async def do_create_document(content_block: str, session_id: Optional[str] = None) -> Dict:
async def do_create_document(content_block: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
"""Create a new document. Supports two formats:
1) Line-based: line 1 = title, line 2 (optional) = language, rest = content
2) XML-like tags: <title>...</title><language>...</language><content>...</content>
@@ -240,6 +262,8 @@ async def do_create_document(content_block: str, session_id: Optional[str] = Non
# Inherit ownership from the chat session so the doc survives that
# session later being deleted (session_id → NULL).
_sess = db.query(DbSession).filter(DbSession.id == session_id).first()
if owner is not None and (not _sess or _sess.owner != owner):
return {"error": "Cannot create document in another user's session"}
_owner = _sess.owner if _sess else None
doc = Document(
@@ -286,7 +310,7 @@ async def do_create_document(content_block: str, session_id: Optional[str] = Non
db.close()
async def do_update_document(content: str, doc_id: Optional[str] = None) -> Dict:
async def do_update_document(content: str, doc_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
"""Update an existing document. Content = full new document text."""
import uuid
from src.database import SessionLocal, Document, DocumentVersion
@@ -297,9 +321,9 @@ async def do_update_document(content: str, doc_id: Optional[str] = None) -> Dict
try:
doc = None
if target_id:
doc = db.query(Document).filter(Document.id == target_id).first()
doc = _get_owned_document(db, Document, target_id, owner)
if not doc:
doc = db.query(Document).order_by(Document.updated_at.desc()).first()
doc = _most_recent_owned_document(db, Document, owner)
if doc:
target_id = doc.id
set_active_document(target_id)
@@ -350,7 +374,7 @@ def parse_edit_blocks(content: str) -> list:
return edits
async def do_edit_document(content: str, doc_id: Optional[str] = None) -> Dict:
async def do_edit_document(content: str, doc_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
"""Apply targeted FIND/REPLACE edits to an existing document."""
import uuid
from src.database import SessionLocal, Document, DocumentVersion
@@ -365,11 +389,11 @@ async def do_edit_document(content: str, doc_id: Optional[str] = None) -> Dict:
try:
doc = None
if target_id:
doc = db.query(Document).filter(Document.id == target_id).first()
doc = _get_owned_document(db, Document, target_id, owner)
if not doc:
# Fallback: most recently updated document. Avoids "no active doc" errors
# after server restart or when the agent loses track of which doc to edit.
doc = db.query(Document).order_by(Document.updated_at.desc()).first()
doc = _most_recent_owned_document(db, Document, owner)
if doc:
target_id = doc.id
set_active_document(target_id)
@@ -458,7 +482,7 @@ def parse_suggest_blocks(content: str) -> list:
return suggestions
async def do_suggest_document(content: str, doc_id: str = None) -> Dict:
async def do_suggest_document(content: str, doc_id: str = None, owner: Optional[str] = None) -> Dict:
"""Create inline suggestions for the active document WITHOUT modifying it."""
from src.database import SessionLocal, Document
@@ -472,7 +496,7 @@ async def do_suggest_document(content: str, doc_id: str = None) -> Dict:
db = SessionLocal()
try:
doc = db.query(Document).filter(Document.id == target_id).first()
doc = _get_owned_document(db, Document, target_id, owner)
if not doc:
return {"error": f"Document {target_id} not found"}
@@ -713,7 +737,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
return {"error": f"Skill {name!r} not found", "exit_code": 1}
if not sk_new.owner:
sk_new.owner = match.get("owner") or owner
ok = sm.update_skill(name, _skill_dump(sk_new))
ok = sm.update_skill(name, _skill_dump(sk_new), owner=owner)
return {"results": f"Edited skill `{sk_new.name}`."} if ok else {"error": "Update failed", "exit_code": 1}
if action == "patch":
@@ -737,7 +761,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
except Exception as e:
return {"error": f"Patched content is not valid SKILL.md: {e}", "exit_code": 1}
sk_new.name = slugify(sk_new.name or name)
ok = sm.update_skill(name, _skill_dump(sk_new))
ok = sm.update_skill(name, _skill_dump(sk_new), owner=owner)
return {"results": f"Patched skill `{sk_new.name}`."} if ok else {"error": "Patch update failed", "exit_code": 1}
if action == "publish":
@@ -750,13 +774,13 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
updates = {"status": "published"}
if args.get("confidence") is not None:
updates["confidence"] = max(0.0, min(1.0, float(args["confidence"])))
sm.update_skill(name, updates)
sm.update_skill(name, updates, owner=owner)
return {"results": f"✅ Published `{name}`. It now appears in the skills index for future turns."}
if action == "delete":
if not name:
return {"error": "name is required for delete", "exit_code": 1}
ok = sm.delete_skill(name)
ok = sm.delete_skill(name, owner=owner)
return {"results": f"Deleted skill `{name}`."} if ok else {"error": f"Skill {name!r} not found", "exit_code": 1}
if action == "search":
@@ -1368,6 +1392,7 @@ async def do_manage_documents(content: str, owner: Optional[str] = None) -> Dict
try:
if action == "list":
q = db.query(Document).filter(Document.is_active == True)
q = _owned_document_query(q, Document, owner)
if args.get("search"):
q = q.filter(Document.title.ilike(f"%{args['search']}%"))
if args.get("language"):
@@ -1398,7 +1423,7 @@ async def do_manage_documents(content: str, owner: Optional[str] = None) -> Dict
doc_id = args.get("document_id") or args.get("id") or args.get("uid")
if not doc_id:
return {"error": "Need document_id (use action=list to find one)", "exit_code": 1}
doc = db.query(Document).filter(Document.id == doc_id, Document.is_active == True).first()
doc = _get_owned_document(db, Document, doc_id, owner, active_only=True)
if not doc:
return {"error": f"Document '{doc_id}' not found", "exit_code": 1}
body = doc.current_content or ""
@@ -1423,10 +1448,10 @@ async def do_manage_documents(content: str, owner: Optional[str] = None) -> Dict
doc_id = args.get("document_id") or args.get("id") or args.get("uid") or _active_document_id
doc = None
if doc_id:
doc = db.query(Document).filter(Document.id == doc_id).first()
doc = _get_owned_document(db, Document, doc_id, owner)
if not doc:
# Fallback: most recently updated doc (likely what the user means)
doc = db.query(Document).filter(Document.is_active == True).order_by(Document.updated_at.desc()).first()
doc = _most_recent_owned_document(db, Document, owner, active_only=True)
if not doc:
return {"error": "No document to delete", "exit_code": 1}
title = doc.title
+13 -5
View File
@@ -110,13 +110,18 @@ export function _foldSummary(label, iconSvg, meta) {
subMeta = '';
}
}
// `meta` is derived from _extractQuoteMeta, which strips tags but then
// un-escapes entities (to recover `<foo@bar.com>` for bubble alignment) —
// so it can carry attacker-controlled angle brackets from a quoted block.
// This summary is built into innerHTML, so escape both parts to stop a
// crafted quote (e.g. `From: <img src=x onerror=...>`) from running script.
const metaSpan = subMeta
? `<span class="email-fold-summary-meta">${subMeta}</span>`
? `<span class="email-fold-summary-meta">${_esc(subMeta)}</span>`
: '';
return (
'<summary class="email-fold-summary">'
+ iconSvg
+ `<span class="email-fold-summary-name">${primary}</span>`
+ `<span class="email-fold-summary-name">${_esc(primary)}</span>`
+ metaSpan
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
+ '</summary>'
@@ -158,9 +163,12 @@ export function _extractQuoteMeta(html) {
if (from.length > 60) from = from.slice(0, 57) + '…';
if (date.length > 28) date = date.slice(0, 25) + '…';
if (from && date) return `${_esc(from)} · ${_esc(date)}`;
if (from) return _esc(from);
if (date) return _esc(date);
// Return the raw sender/date text; `_foldSummary` is the single sink that
// builds these into HTML, so it owns escaping. Escaping here too would
// double-encode (e.g. "Ben & Jerry" -> "Ben &amp;amp; Jerry").
if (from && date) return `${from} · ${date}`;
if (from) return from;
if (date) return date;
return '';
}
+79 -2
View File
@@ -34,6 +34,83 @@ function linkHtml(text, url) {
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${safeText}</a>`;
}
/**
* Sanitize the raw-HTML fragments that mdToHtml deliberately preserves from
* the source text — <details> blocks (collapsible agent output) and <a> tags
* (emitted by the markdown link pass). Those fragments are later restored
* verbatim into innerHTML, so without scrubbing them a model — or any content
* routed through here — could smuggle in an `<img onerror=...>`, an
* `<a href="javascript:...">`, an `onmouseover=` handler, etc. and execute
* script in the authenticated page (DOM XSS).
*
* Parsing into a <template> is inert: assigning to template.innerHTML neither
* fetches resources nor runs scripts, so we can walk the resulting tree,
* drop script-capable elements, and strip event-handler attributes and
* dangerous URL schemes before the (now safe) fragment is handed back.
*/
const _ALLOWED_HTML_BAD_TAGS = new Set([
'SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META',
'STYLE', 'BASE', 'FORM', 'NOSCRIPT', 'TEMPLATE',
// Foreign-content roots. SVG/MathML have their own parser rules and are a
// classic mutation-XSS vehicle — e.g. an SVG-namespaced <script>, whose
// `tagName` is the lower-case 'script' and would slip a name check that
// assumed HTML's upper-casing. They aren't needed in the <details>/<a>
// fragments we preserve, so drop the whole subtree.
'SVG', 'MATH',
]);
const _ALLOWED_HTML_URL_ATTRS = new Set([
'href', 'src', 'xlink:href', 'action', 'formaction', 'background', 'poster',
]);
function _cleanAllowedHtmlOnce(htmlString) {
const tpl = document.createElement('template');
tpl.innerHTML = htmlString;
for (const el of Array.from(tpl.content.querySelectorAll('*'))) {
// Upper-case the tag for comparison: HTML tagNames are upper-case, but
// SVG/MathML elements preserve their original (lower/camel) case, so a
// raw `Set.has(el.tagName)` would miss e.g. a namespaced <script>.
if (_ALLOWED_HTML_BAD_TAGS.has(el.tagName.toUpperCase())) {
el.remove();
continue;
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
// Drop every inline event handler (onerror, onclick, onmouseover, ...)
// and srcdoc (a frame-less script vector).
if (name.startsWith('on') || name === 'srcdoc') {
el.removeAttribute(attr.name);
continue;
}
// Neutralize javascript:/vbscript:/data: in URL-bearing attributes.
// Strip control/space chars first so e.g. "java\tscript:" can't slip by.
if (_ALLOWED_HTML_URL_ATTRS.has(name)) {
const value = (attr.value || '').replace(/[\x00-\x20]+/g, '').toLowerCase();
if (/^(javascript|vbscript|data):/.test(value)) {
el.removeAttribute(attr.name);
}
}
}
}
return tpl.innerHTML;
}
function sanitizeAllowedHtml(html) {
const raw = String(html == null ? '' : html);
// Non-browser context (e.g. a future SSR/Node import): fail closed by
// escaping rather than trusting the markup.
if (typeof document === 'undefined') return escapeHtml(raw);
// Sanitize to a fixpoint. Re-parsing the serialized output can mutate the
// tree (the basis of mutation-XSS), so re-clean until it stops changing.
let out = raw;
for (let i = 0; i < 4; i++) {
const next = _cleanAllowedHtmlOnce(out);
if (next === out) break;
out = next;
}
return out;
}
/**
* Check if text has unclosed think tag
*/
@@ -356,14 +433,14 @@ export function mdToHtml(src) {
// Default to open so agent output is visible
s = s.replace(/<details>([\s\S]*?)<\/details>/gi, (match) => {
const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`;
allowedHtmlBlocks.push(match.replace(/<details>/i, '<details open>'));
allowedHtmlBlocks.push(sanitizeAllowedHtml(match.replace(/<details>/i, '<details open>')));
return placeholder;
});
// ALSO preserve <a> tags the same way (they're now in the HTML from markdown conversion)
s = s.replace(/<a\s+[^>]*>.*?<\/a>/gi, (match) => {
const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`;
allowedHtmlBlocks.push(match);
allowedHtmlBlocks.push(sanitizeAllowedHtml(match));
return placeholder;
});
+4 -3
View File
@@ -1464,6 +1464,7 @@ function _parseTimeSpec(input) {
const mer = (m[4] || '').toLowerCase();
if (mer === 'pm' && hh < 12) hh += 12;
if (mer === 'am' && hh === 12) hh = 0;
if (hh > 23 || mm > 59) return null;
d.setHours(hh, mm, 0, 0);
return { date: d, rest: m[5].trim() };
}
@@ -1477,9 +1478,9 @@ function _parseTimeSpec(input) {
const mer = (m[3] || '').toLowerCase();
if (mer === 'pm' && hh < 12) hh += 12;
if (mer === 'am' && hh === 12) hh = 0;
// Require an hour <= 23 and either a minute field or am/pm to avoid
// eating plain numbers like "3 apples".
if (hh > 23) return null;
// Require a valid hour/minute and either a minute field or am/pm to
// avoid eating plain numbers like "3 apples".
if (hh > 23 || mm > 59) return null;
if (m[2] == null && !mer) return null;
d.setHours(hh, mm, 0, 0);
if (d.getTime() <= now.getTime()) d.setDate(d.getDate() + 1);
+11 -17
View File
@@ -15,7 +15,6 @@ import os
import sys
import types
import asyncio
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -79,23 +78,18 @@ def _login_endpoint(auth_manager):
raise AssertionError("login route not found on the auth router")
def test_login_runs_bcrypt_off_the_event_loop():
loop_thread = threading.get_ident()
seen = {}
def test_login_offloads_bcrypt_bearing_calls(monkeypatch):
calls = []
auth = MagicMock()
def _verify(username, password):
seen["verify_thread"] = threading.get_ident()
return True
async def fake_to_thread(fn, *args, **kwargs):
calls.append(fn)
return fn(*args, **kwargs)
def _create(username, password):
seen["create_thread"] = threading.get_ident()
return "tok-123"
auth.verify_password.side_effect = _verify
monkeypatch.setattr("routes.auth_routes.asyncio.to_thread", fake_to_thread)
auth.verify_password.return_value = True
auth.totp_enabled.return_value = False
auth.create_session.side_effect = _create
auth.create_session.return_value = "tok-123"
login = _login_endpoint(auth)
@@ -108,6 +102,6 @@ def test_login_runs_bcrypt_off_the_event_loop():
assert result["ok"] is True
auth.verify_password.assert_called_once()
auth.create_session.assert_called_once()
# The whole point: the expensive bcrypt calls must NOT run on the loop thread.
assert seen["verify_thread"] != loop_thread, "verify_password ran on the event-loop thread"
assert seen["create_thread"] != loop_thread, "create_session ran on the event-loop thread"
# The whole point: the expensive bcrypt-bearing calls go through
# asyncio.to_thread rather than running inline in the request coroutine.
assert calls == [auth.verify_password, auth.create_session]
+130
View File
@@ -0,0 +1,130 @@
"""Regression tests for password-change session revocation."""
import asyncio
import importlib
import sys
import types
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
def _real_core_package():
root = Path(__file__).resolve().parent.parent
core_path = str(root / "core")
core = sys.modules.get("core")
if core is None:
core = types.ModuleType("core")
sys.modules["core"] = core
core.__path__ = [core_path]
if hasattr(core, "auth"):
delattr(core, "auth")
sys.modules.pop("core.auth", None)
return core
def _auth_module():
_real_core_package()
return importlib.import_module("core.auth")
def _make_manager(tmp_path):
auth_mod = _auth_module()
auth_mod._hash_password = lambda password: f"hash:{password}"
auth_mod._verify_password = lambda password, hashed: hashed == f"hash:{password}"
auth_path = tmp_path / "auth.json"
mgr = auth_mod.AuthManager(str(auth_path))
assert mgr.create_user("alice", "old-password", is_admin=False)
assert mgr.create_user("bob", "bob-password", is_admin=False)
return mgr
async def _immediate_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
def test_revoke_user_sessions_preserves_current_and_persists(tmp_path):
mgr = _make_manager(tmp_path)
current = mgr.create_session("alice", "old-password")
other = mgr.create_session("alice", "old-password")
bob = mgr.create_session("bob", "bob-password")
revoked = mgr.revoke_user_sessions("alice", except_token=current)
assert revoked == 1
assert mgr.validate_token(current) is True
assert mgr.validate_token(other) is False
assert mgr.validate_token(bob) is True
def test_wrong_current_password_does_not_revoke_sessions(tmp_path):
mgr = _make_manager(tmp_path)
current = mgr.create_session("alice", "old-password")
other = mgr.create_session("alice", "old-password")
assert mgr.change_password("alice", "wrong-password", "new-password") is False
assert mgr.validate_token(current) is True
assert mgr.validate_token(other) is True
def test_password_change_allows_new_password_and_blocks_old_password(tmp_path):
mgr = _make_manager(tmp_path)
assert mgr.change_password("alice", "old-password", "new-password") is True
assert mgr.create_session("alice", "old-password") is None
assert mgr.create_session("alice", "new-password") is not None
def _change_password_endpoint(auth_manager):
sys.modules.pop("routes.auth_routes", None)
_real_core_package()
from routes.auth_routes import ChangePasswordRequest, setup_auth_routes
router = setup_auth_routes(auth_manager)
for route in router.routes:
if getattr(route, "path", None) == "/api/auth/change-password":
return route.endpoint, ChangePasswordRequest
raise AssertionError("change-password route not found")
def test_change_password_route_revokes_other_sessions_after_success(monkeypatch):
auth = MagicMock()
auth.get_username_for_token.return_value = "alice"
auth.change_password.return_value = True
endpoint, ChangePasswordRequest = _change_password_endpoint(auth)
monkeypatch.setattr(
"routes.auth_routes.asyncio.to_thread",
lambda fn, *args, **kwargs: _immediate_to_thread(fn, *args, **kwargs),
)
request = SimpleNamespace(cookies={"odysseus_session": "current-token"})
body = ChangePasswordRequest(current_password="old-password", new_password="new-password")
result = asyncio.run(endpoint(body=body, request=request))
assert result == {"ok": True}
auth.change_password.assert_called_once_with("alice", "old-password", "new-password")
auth.revoke_user_sessions.assert_called_once_with("alice", "current-token")
def test_change_password_route_wrong_password_does_not_revoke(monkeypatch):
auth = MagicMock()
auth.get_username_for_token.return_value = "alice"
auth.change_password.return_value = False
endpoint, ChangePasswordRequest = _change_password_endpoint(auth)
monkeypatch.setattr(
"routes.auth_routes.asyncio.to_thread",
lambda fn, *args, **kwargs: _immediate_to_thread(fn, *args, **kwargs),
)
request = SimpleNamespace(cookies={"odysseus_session": "current-token"})
body = ChangePasswordRequest(current_password="wrong-password", new_password="new-password")
with pytest.raises(HTTPException) as exc:
asyncio.run(endpoint(body=body, request=request))
assert exc.value.status_code == 400
auth.revoke_user_sessions.assert_not_called()
+120
View File
@@ -0,0 +1,120 @@
import importlib.machinery
import importlib.util
import io
import tarfile
from pathlib import Path
from types import SimpleNamespace
import pytest
def _load_backup_cli():
path = Path(__file__).resolve().parent.parent / "scripts" / "odysseus-backup"
loader = importlib.machinery.SourceFileLoader("odysseus_backup_under_test", str(path))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
def _patch_repo(module, monkeypatch, root: Path):
monkeypatch.setattr(module, "_REPO_ROOT", root)
monkeypatch.setattr(module, "_DATA_DIR", root / "data")
def _restore_args(path: Path):
return SimpleNamespace(path=str(path), yes=True, pretty=False)
def _verify_args(path: Path):
return SimpleNamespace(path=str(path), pretty=False)
def test_restore_rejects_symlink_escape(tmp_path, monkeypatch):
backup = _load_backup_cli()
repo = tmp_path / "repo"
data = repo / "data"
outside = tmp_path / "outside"
data.mkdir(parents=True)
outside.mkdir()
(data / "keep.txt").write_text("still here", encoding="utf-8")
_patch_repo(backup, monkeypatch, repo)
tar_path = tmp_path / "malicious.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
data_dir = tarfile.TarInfo("data")
data_dir.type = tarfile.DIRTYPE
tar.addfile(data_dir)
link = tarfile.TarInfo("data/link")
link.type = tarfile.SYMTYPE
link.linkname = str(outside)
tar.addfile(link)
payload = b"escaped"
escaped = tarfile.TarInfo("data/link/pwned.txt")
escaped.size = len(payload)
tar.addfile(escaped, io.BytesIO(payload))
with pytest.raises(SystemExit):
backup.cmd_restore(_restore_args(tar_path))
assert not (outside / "pwned.txt").exists()
assert (data / "keep.txt").read_text(encoding="utf-8") == "still here"
def test_verify_rejects_symlink_escape(tmp_path):
backup = _load_backup_cli()
tar_path = tmp_path / "malicious.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
link = tarfile.TarInfo("data/link")
link.type = tarfile.SYMTYPE
link.linkname = "/tmp"
tar.addfile(link)
with pytest.raises(SystemExit):
backup.cmd_verify(_verify_args(tar_path))
def test_restore_rejects_hardlink_entries(tmp_path, monkeypatch):
backup = _load_backup_cli()
repo = tmp_path / "repo"
(repo / "data").mkdir(parents=True)
_patch_repo(backup, monkeypatch, repo)
tar_path = tmp_path / "hardlink.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
link = tarfile.TarInfo("data/hardlink")
link.type = tarfile.LNKTYPE
link.linkname = "../outside.txt"
tar.addfile(link)
with pytest.raises(SystemExit):
backup.cmd_restore(_restore_args(tar_path))
def test_restore_extracts_regular_files_without_extractall(tmp_path, monkeypatch):
backup = _load_backup_cli()
repo = tmp_path / "repo"
data = repo / "data"
data.mkdir(parents=True)
(data / "old.txt").write_text("old", encoding="utf-8")
_patch_repo(backup, monkeypatch, repo)
tar_path = tmp_path / "valid.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
folder = tarfile.TarInfo("data/nested")
folder.type = tarfile.DIRTYPE
tar.addfile(folder)
payload = b"new"
item = tarfile.TarInfo("data/nested/new.txt")
item.size = len(payload)
tar.addfile(item, io.BytesIO(payload))
backup.cmd_restore(_restore_args(tar_path))
assert (repo / "data" / "nested" / "new.txt").read_text(encoding="utf-8") == "new"
assert not (repo / "data" / "old.txt").exists()
assert list(repo.glob("data.before-restore-*"))
+12 -31
View File
@@ -11,38 +11,19 @@ and passes the account owner to do_manage_calendar. This test pins that
get_upcoming_events scopes to the owner; it fails if the owner filter is
dropped (the original cross-tenant behavior).
"""
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
from datetime import datetime, timedelta
from core import database as db
import ast
from pathlib import Path
def test_get_upcoming_events_is_owner_scoped():
db.Base.metadata.create_all(bind=db.engine)
soon = datetime.utcnow() + timedelta(days=2)
end = soon + timedelta(hours=1)
source = Path("core/database.py").read_text()
tree = ast.parse(source)
fn = next(
node for node in tree.body
if isinstance(node, ast.FunctionDef) and node.name == "get_upcoming_events"
)
body = ast.unparse(fn)
s = db.SessionLocal()
try:
s.merge(db.CalendarCal(id="cal-alice", owner="alice", name="Alice"))
s.merge(db.CalendarCal(id="cal-bob", owner="bob", name="Bob"))
s.merge(db.CalendarEvent(uid="ev-alice", calendar_id="cal-alice",
summary="Alice 1:1", dtstart=soon, dtend=end))
s.merge(db.CalendarEvent(uid="ev-bob", calendar_id="cal-bob",
summary="Bob 1:1", dtstart=soon, dtend=end))
s.commit()
finally:
s.close()
alice = {e["uid"] for e in db.get_upcoming_events("alice")}
bob = {e["uid"] for e in db.get_upcoming_events("bob")}
everyone = {e["uid"] for e in db.get_upcoming_events(None)}
# An owner sees ONLY their own events — never the other tenant's.
assert alice == {"ev-alice"}, alice
assert bob == {"ev-bob"}, bob
assert "ev-bob" not in alice and "ev-alice" not in bob
# owner=None is the explicit single-user / legacy escape hatch (unscoped).
assert {"ev-alice", "ev-bob"} <= everyone
assert "join(CalendarCal)" in body
assert "if owner is not None:" in body
assert "q.filter(CalendarCal.owner == owner)" in body
+150
View File
@@ -0,0 +1,150 @@
import asyncio
import sys
import types
from src import tool_implementations as tools
class _Column:
def __init__(self, name):
self.name = name
def __eq__(self, value):
return (self.name, "eq", value)
def desc(self):
return (self.name, "desc")
def ilike(self, value):
return (self.name, "ilike", value)
class _Document:
id = _Column("id")
owner = _Column("owner")
is_active = _Column("is_active")
title = _Column("title")
language = _Column("language")
updated_at = _Column("updated_at")
class _Query:
def __init__(self, docs=None, first_doc=None):
self.filters = []
self.docs = docs or []
self.first_doc = first_doc
def filter(self, *clauses):
self.filters.extend(clauses)
return self
def order_by(self, *args):
return self
def limit(self, *args):
return self
def all(self):
return self.docs
def first(self):
return self.first_doc
class _Db:
def __init__(self, query):
self.query_obj = query
def query(self, *args):
return self.query_obj
def close(self):
pass
def _install_database_stub(monkeypatch, module_name, query):
db = _Db(query)
db_mod = types.ModuleType(module_name)
db_mod.SessionLocal = lambda: db
db_mod.Document = _Document
db_mod.DocumentVersion = object
db_mod.Session = object
monkeypatch.setitem(sys.modules, module_name, db_mod)
return db
def test_owned_document_query_rejects_missing_owner():
query = _Query()
assert tools._owned_document_query(query, _Document, None) is query
assert False in query.filters
def test_owned_document_query_filters_to_owner():
query = _Query()
assert tools._owned_document_query(query, _Document, "alice") is query
assert ("owner", "eq", "alice") in query.filters
def test_manage_documents_list_filters_to_calling_owner(monkeypatch):
query = _Query()
_install_database_stub(monkeypatch, "core.database", query)
result = asyncio.run(tools.do_manage_documents('{"action":"list"}', owner="alice"))
assert result["documents"] == []
assert ("owner", "eq", "alice") in query.filters
def test_manage_documents_read_filters_to_calling_owner(monkeypatch):
query = _Query()
_install_database_stub(monkeypatch, "core.database", query)
result = asyncio.run(
tools.do_manage_documents('{"action":"read","document_id":"doc-bob"}', owner="alice")
)
assert result["exit_code"] == 1
assert ("id", "eq", "doc-bob") in query.filters
assert ("owner", "eq", "alice") in query.filters
def test_update_document_active_id_filters_to_calling_owner(monkeypatch):
query = _Query()
_install_database_stub(monkeypatch, "src.database", query)
tools.set_active_document("doc-bob")
try:
result = asyncio.run(tools.do_update_document("new content", owner="alice"))
finally:
tools.set_active_document(None)
assert result["error"] == "No documents exist to update"
assert ("id", "eq", "doc-bob") in query.filters
assert ("owner", "eq", "alice") in query.filters
def test_suggest_document_active_id_filters_to_calling_owner(monkeypatch):
query = _Query()
_install_database_stub(monkeypatch, "src.database", query)
tools.set_active_document("doc-bob")
try:
result = asyncio.run(tools.do_suggest_document(
"<<<FIND>>>\nold\n<<<SUGGEST>>>\nnew\n<<<REASON>>>\nbetter\n<<<END>>>",
owner="alice",
))
finally:
tools.set_active_document(None)
assert result["error"] == "Document doc-bob not found"
assert ("id", "eq", "doc-bob") in query.filters
assert ("owner", "eq", "alice") in query.filters
def test_document_tool_dispatch_forwards_owner():
source = open("src/tool_execution.py", encoding="utf-8").read()
assert "do_create_document(content, session_id=session_id, owner=owner)" in source
assert "do_update_document(content, owner=owner)" in source
assert "do_edit_document(content, owner=owner)" in source
assert "do_suggest_document(content, owner=owner)" in source
+79
View File
@@ -0,0 +1,79 @@
"""Regression tests for thread-safe access to llm_core's shared maps (issue #659).
The synchronous llm_call() runs inside FastAPI's threadpool (sync route handlers
such as POST /sessions/auto-sort), while llm_call_async() runs on the event
loop. Both mutate the module-level _response_cache / _host_fails / _dead_hosts
dicts, so those mutations must tolerate concurrent access from multiple OS
threads.
Plain thread stress can't reliably reproduce these races (CPython's GIL rarely
preempts the short critical sections), so each test deterministically widens the
vulnerable window: one injects a phantom snapshot key, the other forces every
thread to read the counter before any writes it back.
"""
import threading
import time
from src import llm_core
def test_cache_eviction_tolerates_already_removed_key():
"""Eviction must not raise when a snapshotted key is gone by delete time.
Models a concurrent evictor removing the same key: the old `del` raised
KeyError mid-loop, `pop(key, None)` does not.
"""
class PhantomKeysCache(dict):
def keys(self):
# First key is absent from the dict — as if another thread evicted
# it between the snapshot and the delete.
return ["__phantom_removed__", *super().keys()]
original = llm_core._response_cache
cache = PhantomKeysCache()
for i in range(130): # exceed the 128 cap so the eviction branch runs
cache[f"k{i}"] = "x"
llm_core._response_cache = cache
try:
llm_core._set_cached_response("new-key", "y") # must not raise
assert dict.get(cache, "new-key") == "y"
finally:
llm_core._response_cache = original
def test_host_fail_counter_has_no_lost_updates():
"""Concurrent _mark_host_dead calls must each count exactly once.
A SlowGetDict widens the read-modify-write window so the unguarded
get()+1+set() loses every update but one; the lock serializes them.
"""
url = "http://race.example:1234/v1/chat/completions"
key = llm_core._host_key(url)
class SlowGetDict(dict):
def get(self, *args, **kwargs):
value = super().get(*args, **kwargs)
time.sleep(0.01) # widen the gap between the read and the caller's write
return value
n_threads = 8
barrier = threading.Barrier(n_threads)
original_fails = llm_core._host_fails
original_threshold = llm_core._HOST_FAIL_THRESHOLD
llm_core._host_fails = SlowGetDict()
llm_core._HOST_FAIL_THRESHOLD = 10 ** 9 # never cool: every call is a pure +1
try:
def worker():
barrier.wait() # all threads enter the read window together
llm_core._mark_host_dead(url)
threads = [threading.Thread(target=worker) for _ in range(n_threads)]
for t in threads:
t.start()
for t in threads:
t.join()
assert dict.get(llm_core._host_fails, key) == n_threads
finally:
llm_core._host_fails = original_fails
llm_core._HOST_FAIL_THRESHOLD = original_threshold
+44
View File
@@ -2,6 +2,7 @@
import pytest
import src.model_context as model_context
from src.model_context import _is_local_endpoint, estimate_tokens, _lookup_known
@@ -107,3 +108,46 @@ class TestLookupKnown:
"""Models with :free or :extended suffixes should still match."""
result = _lookup_known("deepseek-r1:free")
assert result == 64000
class TestGetContextLength:
def setup_method(self):
model_context._context_cache.clear()
def test_local_endpoint_requeries_same_model_after_restart(self, monkeypatch):
calls = []
def fake_query(endpoint_url, model):
calls.append((endpoint_url, model))
return 8192 if len(calls) == 1 else 27000
monkeypatch.setattr(model_context, "_query_context_length", fake_query)
endpoint = "http://127.0.0.1:8000/v1/chat/completions"
model = "Qwen/Qwen3-14B"
first = model_context.get_context_length(endpoint, model)
second = model_context.get_context_length(endpoint, model)
assert first == 8192
assert second == 27000
assert len(calls) == 2
def test_remote_endpoint_keeps_cached_context(self, monkeypatch):
calls = []
def fake_query(endpoint_url, model):
calls.append((endpoint_url, model))
return 200000 if len(calls) == 1 else 12345
monkeypatch.setattr(model_context, "_query_context_length", fake_query)
endpoint = "https://api.openai.com/v1/chat/completions"
model = "gpt-5"
first = model_context.get_context_length(endpoint, model)
second = model_context.get_context_length(endpoint, model)
assert first == 200000
assert second == 200000
assert len(calls) == 1
+20
View File
@@ -296,3 +296,23 @@ class TestSetupProbeSafety:
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
assert _probe_endpoint("https://api.anthropic.com/v1") == ANTHROPIC_MODELS
def test_ollama_endpoint_error_message_includes_troubleshooting():
msg = model_routes._model_endpoint_error_message(
"http://localhost:11434/v1",
{"error": "Connection refused"},
)
assert "No Ollama models found" in msg
assert "Connection refused" in msg
assert "http://localhost:11434/v1" in msg
assert "ollama list" in msg
def test_generic_endpoint_error_message_preserves_probe_error():
msg = model_routes._model_endpoint_error_message(
"https://api.example.com/v1",
{"error": "HTTP 401"},
)
assert msg == "No models found for that provider/key. Last probe error: HTTP 401."
@@ -0,0 +1,66 @@
"""Regression: reserved sentinel usernames must not be registerable.
`core.middleware.require_admin` grants admin to any request whose
`current_user == "internal-tool"` (the in-process tool-loopback sentinel),
and the cookie auth path in app.py sets `current_user` to the raw username.
Before this fix nothing reserved that name, so a self-service signup (or an
admin typo) creating the account "internal-tool" was silently treated as an
admin by every `require_admin`-gated route — a privilege escalation. "api"
is reserved for the same reason (bearer-token owner attribution collision).
See the privilege-escalation finding from the 2026-06 code review.
"""
import sys
import pytest
def _fresh_auth_manager(tmp_path):
# Same import dance as test_security_regressions: drop any cached stub so
# we exercise the real module from disk rather than a conftest mock.
sys.modules.pop("core.auth", None)
if "core" in sys.modules and hasattr(sys.modules["core"], "auth"):
delattr(sys.modules["core"], "auth")
from core.auth import AuthManager
return AuthManager(str(tmp_path / "auth.json"))
@pytest.mark.parametrize(
"name",
["internal-tool", "api", "demo", "system", "INTERNAL-TOOL", " Internal-Tool ", "Api", "SYSTEM"],
)
def test_create_user_rejects_reserved_usernames(tmp_path, name):
mgr = _fresh_auth_manager(tmp_path)
assert mgr.create_user(name, "pw-123456") is False
# The normalized name must not have been written to the user table.
assert name.strip().lower() not in mgr.users
def test_create_user_rejects_empty_username(tmp_path):
mgr = _fresh_auth_manager(tmp_path)
assert mgr.create_user(" ", "pw-123456") is False
assert "" not in mgr.users
def test_setup_rejects_reserved_admin_username(tmp_path):
mgr = _fresh_auth_manager(tmp_path)
# First-run admin setup funnels through create_user, so it's covered too.
assert mgr.setup("internal-tool", "pw-123456") is False
assert mgr.is_configured is False
def test_rename_into_reserved_username_is_blocked(tmp_path):
mgr = _fresh_auth_manager(tmp_path)
assert mgr.create_user("admin", "pw-123456", is_admin=True) is True
assert mgr.create_user("bob", "pw-123456") is True
assert mgr.rename_user("bob", "internal-tool", "admin") is False
assert "internal-tool" not in mgr.users
assert "bob" in mgr.users
def test_normal_usernames_still_allowed(tmp_path):
mgr = _fresh_auth_manager(tmp_path)
assert mgr.create_user("alice", "pw-123456") is True
assert "alice" in mgr.users
+36
View File
@@ -97,6 +97,42 @@ def _install_core_auth_stub(monkeypatch):
return auth_mod
def test_providers_requires_admin_before_discovery_and_cache(monkeypatch):
_install_model_route_import_stubs(monkeypatch)
import routes.model_routes as model_routes
class _Discovery:
def __init__(self):
self.calls = 0
def get_providers(self):
self.calls += 1
return {"providers": [{"host": "internal.example"}]}
discovery = _Discovery()
router = model_routes.setup_model_routes(discovery)
endpoint = next(
route.endpoint
for route in router.routes
if getattr(route, "path", "") == "/api/providers"
)
request = SimpleNamespace()
assert endpoint(request, refresh=True) == {"providers": [{"host": "internal.example"}]}
assert discovery.calls == 1
def deny_admin(_request):
raise PermissionError("admin required")
monkeypatch.setattr(model_routes, "require_admin", deny_admin)
with pytest.raises(PermissionError):
endpoint(request, refresh=True)
with pytest.raises(PermissionError):
endpoint(request, refresh=False)
assert discovery.calls == 1
def test_default_chat_does_not_auto_pick_shared_endpoint_for_fresh_user(monkeypatch):
_install_model_route_import_stubs(monkeypatch)
import routes.model_routes as model_routes
@@ -0,0 +1,195 @@
"""Independent validation test for the claim that
`SkillsManager.update_skill` mutates the first skill on disk matching
`name` regardless of the caller's owner, and that `owner` is in its
`scalar_keys` whitelist allowing cross-user ownership reassignment.
This test sets up two user-owned skills on disk with the SAME slug
(`login-flow`) — Alice's and Bob's — and then calls `update_skill` with
NO `owner` argument. If the bug is real, exactly one of the two files
will be mutated (whichever `_iter_skill_files` yields first) and the
caller will have effectively re-stamped the file as owned by the value
in `updates["owner"]` ("attacker"). If the manager method is safe (or
the slug uniqueness invariant makes the bug moot), the call should
either:
* raise (it requires an `owner` argument), OR
* be a no-op (no other side effect on Bob's file), OR
* the file that gets modified should still belong to its original
owner (no ownership reassignment).
We assert the safer behaviors; the test FAILS only when update_skill
silently mutates a file owned by a different user AND overwrites the
`owner` field with an attacker's value.
"""
import os
import sys
import textwrap
import types
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# ── module-load stubbing (matches other tests in this repo) ──────────
# Stub heavy deps so importing the skills manager doesn't pull DB / FastAPI.
for _mod in [
"sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext",
"sqlalchemy.ext.declarative", "src.database",
"core.atomic_io", # we'll patch atomic_write_text below
]:
if _mod not in sys.modules:
sys.modules[_mod] = MagicMock()
# Provide a no-op atomic_write_text for SkillsManager._write_skill.
def _fake_atomic_write_text(path, content, **kw):
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_text(content, encoding="utf-8")
_fake_core = types.ModuleType("core.atomic_io")
_fake_core.atomic_write_text = _fake_atomic_write_text
_fake_core.atomic_write_json = lambda p, d, **kw: Path(p).write_text(
"{}", encoding="utf-8"
)
sys.modules["core.atomic_io"] = _fake_core
from services.memory.skills import SkillsManager # noqa: E402
from services.memory.skill_format import Skill, slugify # noqa: E402
def _write_skill_md(skills_root: Path, category: str, name: str,
owner: str, description: str) -> Path:
"""Drop a real SKILL.md on disk for the given owner."""
skill_dir = skills_root / slugify(category or "general", fallback="general") / name
skill_dir.mkdir(parents=True, exist_ok=True)
md = textwrap.dedent(f"""\
---
name: {name}
description: {description}
version: 1.0.0
category: {category}
tags: []
status: draft
confidence: 0.8
source: learned
owner: {owner}
created: 2026-01-01T00:00:00Z
---
# When to use
test
# Procedure
- step 1
""")
path = skill_dir / "SKILL.md"
path.write_text(md, encoding="utf-8")
return path
def test_update_skill_does_not_mutate_foreign_owned_skill(tmp_path):
"""Two users own distinct skills with the same slug. update_skill()
called WITHOUT an owner argument must not silently overwrite the
wrong file or change its owner field."""
skills_root = tmp_path / "skills"
skills_root.mkdir(parents=True, exist_ok=True)
# Create two distinct on-disk skills with the SAME slug but in
# DIFFERENT category directories so they are real, separately
# addressable files. (The on-disk layout is
# `<category>/<name>/SKILL.md`, so two users can in fact have
# the same slug under different categories — exactly the situation
# that triggers the first-match-wins bug in update_skill.)
alice_path = _write_skill_md(
skills_root, category="alice-cat", name="login-flow",
owner="alice", description="alice original",
)
bob_path = _write_skill_md(
skills_root, category="bob-cat", name="login-flow",
owner="bob", description="bob original",
)
assert alice_path != bob_path
assert alice_path.exists() and bob_path.exists()
sm = SkillsManager(str(tmp_path))
# Snapshot before.
before_alice = alice_path.read_text(encoding="utf-8")
before_bob = bob_path.read_text(encoding="utf-8")
# Try to reassign + mutate. The caller does NOT supply an owner
# arg, mirroring the in-process callers in tool_implementations.py
# (lines 716, 740, 753) which call sm.update_skill(name, updates).
try:
result = sm.update_skill(
"login-flow",
{"owner": "attacker", "description": "pwned"},
)
except TypeError as e:
# If the method were fixed to require an owner arg, this is
# the desired (safe) behavior — the call refused.
pytest.skip(
f"update_skill raised TypeError (refused unsafe call): {e}"
)
return
# After: read what each file now contains.
after_alice = alice_path.read_text(encoding="utf-8")
after_bob = bob_path.read_text(encoding="utf-8")
# Invariant 1: a file that was owned by `alice` (resp. `bob`) MUST
# NOT end up owned by `attacker` after the call. If it does, that's
# the cross-user ownership reassignment bug.
assert "owner: attacker" not in after_alice, (
"BUG: Alice's file was silently re-owned as 'attacker' by "
"update_skill (cross-user ownership reassignment)."
)
assert "owner: attacker" not in after_bob, (
"BUG: Bob's file was silently re-owned as 'attacker' by "
"update_skill (cross-user ownership reassignment)."
)
# Invariant 2: a file that was owned by `alice` and contained
# description "alice original" must not be silently mutated into
# "pwned" by a caller that did not supply an owner.
if "alice original" in before_alice:
assert "alice original" in after_alice, (
"BUG: Alice's skill description was overwritten by a call "
"to update_skill that did not scope to her owner."
)
if "bob original" in before_bob:
assert "bob original" in after_bob, (
"BUG: Bob's skill description was overwritten by a call "
"to update_skill that did not scope to his owner."
)
# The return value should not lie about success — if the manager
# touched nothing because both files were foreign-owned, the safer
# behavior is to return False, not True. (A return of True is the
# buggy path; we don't assert False, we just don't assert True.)
_ = result # not asserted; documented behavior is not the point.
def test_update_skill_scalar_keys_exclude_owner():
"""Static check: the manager's scalar_keys whitelist MUST NOT
include 'owner' — otherwise a non-owner caller can pass
updates={'owner': 'attacker'} and reassign the file. The fix
removed 'owner' from scalar_keys; this test now asserts the
fix is in place."""
src = Path("services/memory/skills.py").read_text(encoding="utf-8")
import re
m = re.search(
r"def update_skill\(.*?scalar_keys\s*=\s*\((.*?)\)",
src,
re.DOTALL,
)
assert m, "could not locate scalar_keys tuple in update_skill"
body = m.group(1)
assert '"owner"' not in body and "'owner'" not in body, (
"BUG (regression): scalar_keys in update_skill includes 'owner'. "
"The fix removed this to prevent cross-user ownership reassignment "
"via the updates dict."
)