mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-01 10:42:23 +01:00
* feat(mcp): let a deployment declare local MCP tools Tools served by a user-configured MCP server are absent from TOOL_CAPABILITIES, so they are unknown and fail high. For a server with both a read and a write tool that is unusable: the read result arms the post-external gate, because an unknown result is not SYSTEM integrity, and the write that follows is refused until separately authorized. Every second call needs an approval, for a server that never leaves the host. Add ODYSSEUS_LOCAL_MCP_READ_TOOLS and ODYSSEUS_LOCAL_MCP_WRITE_TOOLS. Declared read tools become READ_PRIVATE with SYSTEM integrity, so their output no longer arms the gate; declared write tools become WRITE_PRIVATE and stay inside POST_EXTERNAL_BLOCKED_EFFECTS, so a run tainted by real external content still requires the approval. Unset variables keep the current behaviour exactly. Fixes #6329 * fix(mcp): secure local MCP authorization and preserve external taint gate Keep declared local MCP read tool results untrusted with EXTERNAL_UNTRUSTED integrity so they continue to arm the external-context gate. Introduce a narrowly scoped continuation exception for declared local MCP writes on the same server when no external taint or explicit error payloads enter the run. Preserves gate protection for shell execution, network egress, email, and cross-server actions. Fixes #6329 * fix(mcp): resolve local continuation against stdio transport and enforce built-in classifications Resolve declared local MCP continuation operations against connected stdio servers and enforce established built-in tool security classifications. Ensure remote HTTP and SSE servers, unverified transports, replaced sessions, stale configurations, and operations with dangerous effects or missing request grants fail closed. * fix(mcp): require sealed authority for local continuation * fix(mcp): seal local tool admission and synchronize GPU stacks --------- Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
94 lines
9.0 KiB
Markdown
94 lines
9.0 KiB
Markdown
# 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.json` via `core/atomic_io.py`.
|
|
- **2FA:** TOTP with 8 single-use backup codes. Verified after password check, before session issuance.
|
|
- **Reserved usernames:** request sentinels and the Default/Local storage owner cannot be registered or renamed into. Defined in `core/auth.py:RESERVED_USERNAMES`.
|
|
- `internal-tool` is security-critical: `core/middleware.py:require_admin` treats any request where `request.state.current_user == "internal-tool"` as the in-process tool loopback and grants admin unconditionally. A real account with that name would silently pass every `require_admin` check.
|
|
- **Orphan sessions:** `validate_token` re-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:
|
|
|
|
1. At app startup, `core/middleware.py` generates a random `INTERNAL_TOOL_TOKEN` via `secrets.token_hex(32)`. It is never persisted and never sent to clients.
|
|
2. Loopback requests carry `X-Odysseus-Internal-Token: <token>` or have `request.state.current_user` already set to `"internal-tool"` by the auth middleware.
|
|
3. `require_admin` recognises 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
|
|
|
|
External content that reaches the LLM is treated as untrusted via `src/prompt_security.py`:
|
|
|
|
- `untrusted_context_message(label, content)` wraps the content in a `user`-role message with a header block instructing the model not to follow instructions inside it. Content goes in as data, not as a system instruction.
|
|
- `UNTRUSTED_CONTEXT_POLICY` is 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.
|
|
|
|
### Post-external-context tool approval gate — on by default
|
|
|
|
`src/tool_capabilities.py` carries a second layer: once untrusted content has entered a run, `ToolRunSecurityContext.decision_for()` blocks tools that execute code, mutate state, or cause external side effects until the user authorises the action separately.
|
|
|
|
It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation.
|
|
|
|
Three exemptions apply even when the gate is on, all deliberate:
|
|
|
|
- Sources in `_CONTROL_PLANE_CONTEXT_SOURCES` (skills, runtime descriptors, the open editor document, the open email, uploaded files) are treated as control-plane metadata and still permit read-only tools.
|
|
- A TUI run that advertises a host shell bridge and declares `unattended_mode` exempts the local execution set in `TUI_CLIENT_TOOL_NAMES`. Personal, network and deployment-local tools are never exempted.
|
|
- Narrow continuation for local MCP tools declared via `ODYSSEUS_LOCAL_MCP_READ_TOOLS` and `ODYSSEUS_LOCAL_MCP_WRITE_TOOLS`: Only explicitly configured stdio MCP servers qualify; remote HTTP and SSE servers fail closed. Declarations never override built-in tool effects, so built-in email, browser, memory, image generation, and other built-in servers cannot receive continuation exceptions. Declared read tools remain untrusted (`EXTERNAL_UNTRUSTED`) to ensure that prompt injections in local data cannot reach shell, network, or unrelated tools without approval. Stdio transport does not guarantee network isolation, as local processes may still reach the host network. However, declared non-destructive write tools on the exact same stdio MCP server are permitted to continue without an approval prompt if the only untrusted context in the run was produced by declared local reads on that same server, without any explicit error or external taint. Genuinely external content (web search, web fetch, emails, external message context) blocks these writes. Authorization must still originate from the trusted request: request authority must permit the tool and retain the sealed backend resource grant. The residual same-store write risk (the agent writing to the same local store after reading a local prompt injection) remains subject to the user's initial request 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: nosniff` and `Referrer-Policy: no-referrer` everywhere.
|
|
- **CSP:** nonce-based `script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net`. `style-src 'unsafe-inline'` is intentionally kept — `static/index.html` ships inline `<style>` blocks and JS modules set `style=""` 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:
|
|
|
|
1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal. The tool approval gate above is the compensating control and is on by default; turning it off leaves this gap unmitigated beyond the untrusted-context wrapper.
|
|
|
|
2. **SSRF via `/api/v1/chat` `base_url` parameter.** A chat-scoped API token can supply an arbitrary `base_url`; the server forwards the LLM request to that host without validating the scheme or address. PR #1039 fixes this.
|
|
|
|
3. **`src/search/` partial consolidation.** `src.search.core` and `src.search.providers` correctly alias `services.search` via `sys.modules` replacement. `analytics`, `cache`, `content`, `query`, and `ranking` are still independent copies that can drift. The SSRF regression tests in `tests/test_webhook_ssrf_resilience.py` test `src.webhook_manager` directly (separate from search), so the safety net there is intact. See #1058.
|
|
|
|
4. **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 `chat` or `admin` scope with no per-capability granularity.
|