9.3 KiB
Threat Model
Odysseus is a self-hosted AI workspace with privileged local access. This document states the trust boundary so contributors can reason about security decisions without reading through the full auth and middleware stack.
Trust Boundary
Odysseus is designed for trusted users on a private network, not public exposure. The README describes it as "treat it like an admin console" — that framing is accurate. A logged-in admin can execute shell commands, read and write files, send email, and control model serving. This is intentional. The threat model does not try to prevent admins from doing these things. It does try to prevent:
- Unauthenticated access
- Non-admins reaching admin-only capabilities
- The AI agent acting on instructions injected through untrusted content (web results, emails, fetched pages, memories)
- Internal services (ChromaDB, Ollama, SearXNG, etc.) being reachable from outside the host
Roles and Capabilities
| Capability | Admin | Non-admin (default) |
|---|---|---|
| Chat with agent | ✓ | ✓ |
| Browser tool | ✓ | ✓ |
| Documents | ✓ | ✓ |
| Research mode | ✓ | ✓ |
| Image generation | ✓ | ✓ |
| Memory management | ✓ | ✓ |
| Shell / Python execution | ✓ | ✗ |
| File read / write | ✓ | ✗ |
| Email send / read | ✓ | ✗ |
| MCP tools | ✓ | ✗ |
| Calendar management | ✓ | ✗ |
| Token / webhook management | ✓ | ✗ |
| Model serving | ✓ | ✗ |
| Vault | ✓ | ✗ |
| Settings | ✓ | ✗ |
Non-admin defaults are in core/auth.py:DEFAULT_PRIVILEGES. Tool enforcement is in src/tool_security.py:NON_ADMIN_BLOCKED_TOOLS. Any tool whose name starts with mcp__ is also blocked for non-admins. Admins always get full access regardless of stored privilege values.
Authentication
- Sessions: bcrypt passwords, 7-day session tokens stored atomically in
data/sessions.jsonviacore/atomic_io.py. - 2FA: TOTP with 8 single-use backup codes. Verified after password check, before session issuance.
- Reserved usernames:
internal-tool,api,demo,systemcannot be registered or renamed into. Defined incore/auth.py:RESERVED_USERNAMES.internal-toolis security-critical:core/middleware.py:require_admintreats any request whererequest.state.current_user == "internal-tool"as the in-process tool loopback and grants admin unconditionally. A real account with that name would silently pass everyrequire_admincheck.
- Orphan sessions:
validate_tokenre-checks that the user record still exists on every call. A deleted user's cookie is dropped on next request rather than continuing to authenticate.
Internal Tool Loopback
Agent tool calls reach admin-gated HTTP routes over an in-process HTTP loopback. The mechanism:
- At app startup,
core/middleware.pygenerates a randomINTERNAL_TOOL_TOKENviasecrets.token_hex(32). It is never persisted and never sent to clients. - Loopback requests carry
X-Odysseus-Internal-Token: <token>or haverequest.state.current_useralready set to"internal-tool"by the auth middleware. require_adminrecognises either signal and grants access without checking the session user.
The agent may be running in a non-admin user's session, but tool dispatch first calls src/tool_security.py:owner_is_admin_or_single_user to verify the session owner is an admin before issuing any loopback call. Non-admin users cannot invoke admin tools even via the agent.
Prompt-Injection Hardening
Content provenance has two independent dimensions:
- Integrity origin: system, Odysseus-stored/user-editable, workspace/PR, or external.
- Sensitivity: public, workspace, or private.
External, workspace/PR, and Odysseus-stored content are all untrusted as instructions. Private is not an integrity claim; it means later egress can disclose user data even when the content itself is benign.
Content that reaches the LLM is labelled via src/prompt_security.py:
untrusted_context_message(label, content, origin=..., sensitivity=...)wraps content in auser-role message with server-owned provenance metadata and a header block instructing the model not to follow instructions inside it. Content goes in as data, not as a system instruction.UNTRUSTED_CONTEXT_POLICYis a system-prompt preamble that states the same policy at the top of every session where untrusted data may appear.
Untrusted surfaces that must go through this wrapper: web search results, fetched URLs, emails (read), saved memories, skill text, notes, and any tool output sourced from outside the server. Injecting untrusted content directly into the system role is a security bug.
The thread maintains monotonic external, workspace, Odysseus, and private observations in the database. Switching models, starting a later turn, teacher takeover, reloading the browser, or forking the conversation cannot clear them. Agent mode does not ambiently retrieve saved memories or personal-document RAG; those private reads go through exact tool approval. Directly opened documents, emails, and uploads are explicit selections and are labelled before the model sees them.
Agent Run Authority
Model output requests an action; it does not authorize one. src/tool_capabilities.py classifies each built-in tool's effects and result integrity, while src/agent_run_policy.py combines those fixed classifications with the thread's server-owned security mode:
- Ask: public and workspace observation can proceed, but private reads, writes, code execution, egress, external side effects, UI effects, admin changes, destructive actions, and unknown tools require an exact approval.
- Sandbox (default): code execution stays inside the workspace sandbox. Private reads, network egress, external side effects, admin changes, and destructive actions always require an exact approval. Once any untrusted origin has influenced the thread, any later high-impact action also requires approval. Once workspace or private data has influenced it, brokered search/fetch and all other egress require approval of the exact outgoing action.
- Full access: an admin or intentional single-user deployment may explicitly opt into direct execution with that user's normal OS permissions. This is never the default, and route, agent-loop, and dispatcher gates reject it for non-admin users.
Multiplexed tools such as manage_memory, manage_documents, and manage_tasks are classified from the sealed action discriminator. Known read actions get private-read authority, known writes get write authority, and malformed or unknown actions get the fail-high union.
An approval is an opaque, expiring, one-use server record bound to the owner, session, origin run, exact tool name and input, workspace, security mode, effect classification, and complete provenance snapshot. The browser submits only the opaque approval ID and the user's approve/deny decision. On approval, the server executes its sealed copy before the next model turn; natural-language confirmation and a model-repeated or modified command carry no authority.
Security Headers
core/middleware.py:SecurityHeadersMiddleware sets headers on every response:
X-Frame-Options: DENY+frame-ancestors 'none'on all routes except tool-render iframes (which are sandboxed at the HTML level).X-Content-Type-Options: nosniffandReferrer-Policy: no-referrereverywhere.- CSP: nonce-based
script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net.style-src 'unsafe-inline'is intentionally kept —static/index.htmlships inline<style>blocks and JS modules setstyle=""attributes at runtime. Inline styles do not execute script so the risk is visual-only. Removing this requires templating the HTML files and auditing all JS-set style attributes.
Known Gaps
These are open, acknowledged, and contributor help is welcome:
-
Linux sandbox portability. Agent
bash, Python, tmux, and detached background commands run through a networkless bubblewrap profile with a cleared environment, private temp/home, a single writable workspace, credential-path overlays, read-only.gitmetadata, resource limits, and explicit hiding of Odysseus data/log roots even when they sit below a broader selected workspace. The Docker image includes bubblewrap. Sandboxed process execution fails closed when that profile is unavailable; a portable equivalent for non-Linux hosts is not implemented yet. The sandbox intentionally omits/proc, so commands that require process inspection degrade rather than gaining access to the app process namespace. -
SSRF via
/api/v1/chatbase_urlparameter. A chat-scoped API token can supply an arbitrarybase_url; the server forwards the LLM request to that host without validating the scheme or address. PR #1039 fixes this. -
src/search/partial consolidation.src.search.coreandsrc.search.providerscorrectly aliasservices.searchviasys.modulesreplacement.analytics,cache,content,query, andrankingare still independent copies that can drift. The SSRF regression tests intests/test_webhook_ssrf_resilience.pytestsrc.webhook_managerdirectly (separate from search), so the safety net there is intact. See #1058. -
Token scopes are coarse. There is no way to grant a session a subset of the owning user's privileges. Companion/mobile tokens carry either
chatoradminscope with no per-capability granularity.