Contents
odysseus-dev/odysseus describes itself as “a self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows.” As of 2026-10-20, the GitHub API reports 94,034 stars, 1,697 forks, 1,469 open issues, Python as the primary language, creation on 2026-05-31, and an AGPL-3.0-or-later license. Odysseus is a deliberately privileged, single-tenant AI workspace whose security posture rests on a hard trust boundary, trusted admins on a private network, rather than on sandboxing. Its self-published threat model is candid about that trade. The README marks dev as the newest branch and main as the curated one; stars are repo-level, so branch attribution cannot be checked from them.
How This Review Was Researched
This review is based on repository source, docs, and the issue tracker. We did not run it hands-on. Last reviewed: October 2026. The files read were docker-compose.yml, THREAT_MODEL.md, core/middleware.py, core/auth.py, core/session_manager.py, and the README.
Two gaps in that reading affect what follows. app.py is 55,918 bytes and was not read in full, so this review cites no route-level internals from it. SECURITY.md and ROADMAP.md exist in the tree but were not analyzed. The CI signal is limited to workflow filenames (ci, codeql, container-trivy, container-scan, secret-scan, dependency-review, workflow-security). Those names indicate intended checks, not verified enforcement.
Architecture: Four Services and One Privileged Process
The Compose file defines four services: the app (odysseus), chromadb, searxng, and ntfy. The app publishes on ${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}, and the other three publish on loopback by default, which matches the threat model’s goal of keeping internal services off the public interface.
The bind settings differ in how overridable they are. ChromaDB and ntfy read ${CHROMADB_BIND:-127.0.0.1} and ${NTFY_BIND:-127.0.0.1}, so loopback is a default rather than a fixed rule. SearXNG is hard-bound to 127.0.0.1:8080. The SearXNG image is pinned to searxng/searxng:2026.5.31-7159b8aed instead of :latest, because the file’s own comment reports that a :latest build (2026.6.2) crashed on boot with KeyError 'default_doi_resolver', citing issue #1414. That container also drops all capabilities and adds back only CHOWN, SETGID, SETUID, and DAC_OVERRIDE, citing issue #721. This review did not test whether that set is sufficient.
Persistent state lives under ./data, with the default DATABASE_URL=sqlite:///./data/app.db. The SSH identity for Cookbook remote servers is mounted from ./data/ssh, and model caches and installed serve engines sit under ./data/huggingface and ./data/local. The entrypoint drops to PUID/PGID (default 1000) after chowning /app/data and /app/logs. An extra_hosts entry maps host.docker.internal so the container can reach an Ollama server on the host. Upload caps are environment-tunable; the defaults are 10 MiB for chat uploads and 100 MiB for gallery uploads.
The Trust Boundary: An Admin Console by Design
The stated trust boundary is a “self-hosted AI workspace with privileged local access,” designed for trusted users on a private network and not for public exposure. THREAT_MODEL.md says the README describes it as something to “treat it like an admin console.” That framing is the central architectural decision, not a defect.
A logged-in admin can execute shell commands, read and write files, send email, and control model serving, and the threat model presents all of these as intentional. The workspace is useful because it is not walled off from the host’s tools and data. Its security therefore centres on who can become an admin and how untrusted content reaches an admin session, not on containing what an admin session can do.
Non-admin defaults are set in core/auth.py:DEFAULT_PRIVILEGES, and tool enforcement is set in src/tool_security.py:NON_ADMIN_BLOCKED_TOOLS. By default a non-admin lacks shell and Python execution, file read and write, email, MCP tools, calendar management, token and webhook management, model serving, the vault, and settings. Any tool whose name starts with mcp__ is blocked for non-admins.
Agent tool calls do not simply run as the requesting user. At startup, core/middleware.py generates an INTERNAL_TOOL_TOKEN with secrets.token_hex(32), unless ODYSSEUS_INTERNAL_TOKEN is set. Tool calls reach admin-gated routes over an in-process HTTP loopback, carrying either the X-Odysseus-Internal-Token header or a current_user of internal-tool. Before dispatch, src/tool_security.py:owner_is_admin_or_single_user checks that the session owner is an admin. The threat model names the hazard directly: an ordinary account called “internal-tool” would pass every require_admin check, which is why RESERVED_USERNAMES exists in core/auth.py.
Security Model: Authentication, Sessions, and HTTP Headers
Authentication in core/auth.py combines bcrypt password hashing, seven-day session tokens, and TOTP two-factor authentication. Passwords are hashed with bcrypt.hashpw and bcrypt.gensalt and checked with bcrypt.checkpw. TOKEN_TTL is 60*60*24*7. TOTP uses pyotp with eight single-use backup codes and runs after the password check and before a session is issued. The threat model says sessions are written atomically through core/atomic_io.py. validate_token re-reads the user record on each call, so a deleted user’s cookie stops working.
Response hardening lives in SecurityHeadersMiddleware in core/middleware.py. It generates a per-request CSP nonce and applies a default policy of script-src 'self' 'nonce-…' plus cdn.jsdelivr.net, with frame-ancestors 'none'. The default X-Frame-Options is DENY, but the rules vary by route. Tool-render endpoints skip framing headers. The PDF preview endpoint gets SAMEORIGIN with a restrictive CSP. Research report pages get a looser CSP that allows inline scripts, because they are self-contained HTML. Every response also carries X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer, and HSTS (max-age=31536000) is added when the request is HTTPS or X-Forwarded-Proto is HTTPS.
The middleware also has a documented residual risk. style-src 'unsafe-inline' stays because the front end ships inline <style> blocks and sets style attributes at runtime. The code comment calls the residual risk visual-only, since inline styles cannot run script. This review reports that as the project’s own claim, not as an independent finding.
core/session_manager.py adds persistence details. Message metadata is stored as JSON text, with a JSONDecodeError fallback when it cannot be parsed. Deleting a session calls cleanup_session_images, and a cleanup failure is logged as a warning rather than raised. The module also defines cleanup_empty_sessions(auto_archive_days=30, min_age_hours=1).
Prompt Injection: The Untrusted-Context Pattern
The threat model names four untrusted surfaces: web results, emails, fetched pages, and saved memories. External content is supposed to pass through src/prompt_security.py:untrusted_context_message(label, content), which wraps it in a user-role data block. A UNTRUSTED_CONTEXT_POLICY preamble in the system prompt tells the model not to follow instructions inside that data. The project treats injecting such content into the system role as a security bug.
This is defense at the prompt layer, not the execution layer. Wrapping lowers the chance that a model treats retrieved text as instructions, but it does nothing to limit what a shell-enabled admin session can do if the model is persuaded anyway. That gap is why the next section matters.
Self-Disclosed Gaps
The threat model lists four open gaps. It is the project’s own document, and it reads as a list of things not yet solved, not a list of fixed items.
- No shell or filesystem sandbox. Agent
bash,read_file, andwrite_filerun as the app process user, with no egress filtering and no filesystem confinement. A prompt injection that reaches a shell-enabled admin session can make outbound requests to internal services. A sandbox proposal is tracked in issue #1058. - SSRF through the chat
base_url. A chat-scoped API token can supply an arbitrarybase_urlon/api/v1/chat, and the server forwards the request without validating the scheme or address. THREAT_MODEL.md lists this as open and notes that PR #1039 fixes it. - Partial search consolidation.
src/search/core and providers aliasservices.search, but analytics, cache, content, query, and ranking remain separate copies that can drift. - Coarse token scopes. Companion and mobile tokens carry either
chatoradminscope, with no per-capability granularity.
This review could not verify whether #1058 or PR #1039 has merged to the dev branch. Both are described as open in the threat model text this review read on 2026-10-20, so treat them as unresolved until the repository shows otherwise.
How Odysseus Compares to Open WebUI, LibreChat, and AnythingLLM
Only official documentation was read for the three alternatives, so the comparison is at the level of scope and deployment. Odysseus facts come from its repository. Open WebUI, LibreChat, and AnythingLLM facts come from docs.openwebui.com, librechat.ai/docs, and docs.anythingllm.com. Cells the reading could not support say “not verified.”
| Dimension | Odysseus | Open WebUI | LibreChat | AnythingLLM |
|---|---|---|---|---|
| Positioning | Self-hosted AI workspace (README) | Self-hosted AI platform (docs) | Self-hosted web application, not a desktop app (docs) | Desktop and Docker installation (docs) |
| Feature scope | Chat, agents, research, documents, email, notes and calendar, cookbook (README) | Extensible plugin and function model, Ollama and OpenAI-compatible APIs (docs) | Scope-level only (docs) | Docs list all features; v1.18.0 stated as live (docs) |
| Deployment | Four-service Docker Compose (compose file) | :main and :slim image variants (docs) | Docker, npm, or remote hosting guides (docs) | Desktop or Docker (docs) |
| Auth model | bcrypt, TOTP 2FA, role table (THREAT_MODEL, auth.py) | not verified | not verified | not verified |
| Sandboxing | None; self-disclosed gap (#1058) | not verified | not verified | not verified |
| License | AGPL-3.0-or-later (GitHub API) | not verified | not verified | not verified |
| Resource footprint | not verified; not run | Docs quote about 300 MiB RAM idle for :slim in their own test | not verified | not verified |
The only footprint figure in the table comes from Open WebUI’s own test. It is not a measurement from this review.
FAQ
The questions below come up most often about Odysseus. The answers draw on the repository’s README, THREAT_MODEL.md, and docker-compose.yml. None of them claims that this review ran the software.
Is Odysseus safe to expose to the public internet?
No. THREAT_MODEL.md says the project is designed for trusted users on a private network, and it describes the app as an admin console. The Compose file binds the app and the internal services to loopback by default, and authentication is enabled by default. Public exposure would place an admin-capable workspace on an untrusted network.
What are the disclosed security gaps?
The threat model lists no execution sandbox (#1058), SSRF through the chat base_url (PR #1039 is described as the fix), partial search consolidation in src/search/, and coarse token scopes. This review did not verify whether #1058 or #1039 has merged.
How does Odysseus defend against prompt injection?
untrusted_context_message wraps external content as user-role data, and a UNTRUSTED_CONTEXT_POLICY preamble tells the model not to obey it. The project treats system-role injection as a security bug. No execution-layer sandbox backs this up, so the defense is at the prompt level only.
The Bottom Line
Teams that want a single-tenant, self-hosted workspace on a private network, and that accept admin-level trust, should read the threat model first. Watch whether sandbox issue #1058 and SSRF PR #1039 merge, whether search-module drift grows, and whether token scopes ever become per-capability. This review did not run the software, so runtime behavior is unverified.




