Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,28 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
nodejs \
npm \
chromium \
bubblewrap \
libseccomp2 \
util-linux \
tmux \
openssh-client \
gosu \
libgl1 \
libglib2.0-0t64 \
libxcb1 \
libmagic1 \
&& BWRAP_POLICY_VERSION=0.11.0 \
&& BWRAP_POLICY_PACKAGE=0.11.0-2+deb13u1 \
&& BWRAP_ACTUAL="$(bwrap --version)" \
&& BWRAP_PACKAGE="$(dpkg-query -W -f='${Version}' bubblewrap)" \
&& if [ "$BWRAP_ACTUAL" != "bubblewrap ${BWRAP_POLICY_VERSION}" ]; then \
echo "unsupported Bubblewrap version: $BWRAP_ACTUAL (expected ${BWRAP_POLICY_VERSION})" >&2; \
exit 1; \
fi \
&& if [ "$BWRAP_PACKAGE" != "$BWRAP_POLICY_PACKAGE" ]; then \
echo "unsupported Bubblewrap package: $BWRAP_PACKAGE (expected $BWRAP_POLICY_PACKAGE)" >&2; \
exit 1; \
fi \
&& rm -rf /var/lib/apt/lists/*

# libgl1/libglib2.0-0t64/libxcb1 are runtime shared libs (libGL.so.1,
Expand Down Expand Up @@ -95,6 +110,14 @@ RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
# Copy app code
COPY . .

# Compile and install the fixed-purpose inner-seccomp launcher and constrained
# HTTP(S) egress broker under a root-owned path that the dropped runtime user
# and model workspace cannot modify. The policy generator verifies pinned Moby
# provenance first.
RUN make -C security/seccomp install \
&& make -C security/egress install \
&& rm -rf security/seccomp/build

# Create data directory (mount a volume here for persistence)
RUN mkdir -p data logs services/cache/search

Expand Down
2 changes: 1 addition & 1 deletion THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se

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.
1. **Linux sandbox portability and explicit Full Access.** Agent `bash`, Python, tmux, and detached background commands default to a Bubblewrap profile with a private network namespace, cleared environment, private temp/home, one writable workspace, credential-path overlays, read-only `.git` metadata, and generous per-process rlimits. Internet-enabled process execution is limited to the trusted HTTP(S) egress broker; raw container networking is never shared with either mode. Odysseus performs the actual capability probes under the service user at startup and before process execution. A failed probe blocks only process tools and never downgrades automatically. An administrator may temporarily enable **Full Access** only after a warning plus typed confirmation. Full Access grants the process the Odysseus operating-system user's filesystem view while retaining the private PID/network namespace and brokered-only Internet policy; in Docker that filesystem authority includes the container and mounted volumes, while native execution includes everything available to the service user. New process launches reset to Sandbox at application restart; an already-running Full Access process retains its launch-time authority until it exits or is killed. The process boundary mounts a fresh procfs scoped to its private PID namespace. Hard workspace-disk quotas and aggregate agent-pool/per-instance CPU, memory, and PID ceilings are not established in this slice; current limits are per process and the aggregate design is tracked separately. The supported default Compose path must not require `privileged: true`, a globally unconfined profile, or an automatic Full Access fallback.

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.

Expand Down
32 changes: 32 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,38 @@ async def _lifespan(app):
async def _startup_event():
global upload_cleanup_task
logger.info("Application starting up...")
try:
from src.process_execution import (
process_capability,
reset_process_execution_mode,
)

# Full Access is intentionally transient: every application start returns
# to the least-authority Sandbox default and requires a new confirmation.
reset_process_execution_mode()
capability = await asyncio.to_thread(process_capability, refresh=True)
if capability.sandbox.networkless:
logger.info("Agent process Sandbox capability probe passed")
if not capability.sandbox.brokered:
logger.warning(
"Brokered process Internet is unavailable in Sandbox mode: %s",
capability.sandbox.brokered_reason,
)
else:
logger.warning(
"Agent process Sandbox unavailable; Bash, Python, tmux, and "
"detached jobs remain blocked by default: %s",
capability.sandbox.networkless_reason,
)
if not capability.full_access.networkless:
logger.warning(
"Explicit Full Access process mode is also unavailable: %s",
capability.full_access.networkless_reason,
)
except Exception:
logger.exception(
"Agent process capability probe failed; process tools remain blocked"
)
webhook_manager.set_loop(asyncio.get_running_loop())
# Wipe any leftover incognito sessions from previous process — they're
# ephemeral by design and must not survive a restart.
Expand Down
75 changes: 7 additions & 68 deletions core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from urllib.parse import unquote, urlparse
from src.sqlite_paths import (
normalize_sqlite_url as _normalize_sqlite_url_impl,
sqlite_db_path as _sqlite_db_path_impl,
)
from sqlalchemy import DDL, event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, inspect, text
from sqlalchemy.engine import Engine, make_url
from sqlalchemy.engine import Engine
from sqlalchemy.types import TypeDecorator
from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.orm import relationship, sessionmaker, backref
Expand Down Expand Up @@ -45,28 +48,7 @@ def _default_database_url() -> str:


def _normalize_sqlite_url(url: str) -> str:
"""Resolve relative ordinary SQLite paths without rewriting URI filenames."""
try:
parsed = make_url(url)
except Exception:
return url

if parsed.get_backend_name() != "sqlite":
return url

db_path = parsed.database
if (
not db_path
or db_path == ":memory:"
or str(db_path).lower().startswith("file:")
or os.path.isabs(str(db_path))
):
return url

absolute_path = (Path(get_app_root()) / str(db_path)).resolve().as_posix()
return parsed.set(database=absolute_path).render_as_string(
hide_password=False
)
return _normalize_sqlite_url_impl(url, app_root=get_app_root())


# Get database URL from environment, default to SQLite in DATA_DIR
Expand All @@ -86,50 +68,7 @@ def _normalize_sqlite_url(url: str) -> str:


def _sqlite_db_path(url) -> Optional[str]:
"""Return the filesystem path for a file-backed SQLite URL.

SQLite query parameters such as ``mode=memory`` only affect filename
semantics when SQLAlchemy enables URI handling with ``uri=true``. Ordinary
file URLs must therefore remain file-backed even when they contain a query
parameter named ``mode``.

For SQLite ``file:`` URIs, an empty authority or ``localhost`` identifies a
local path. Other authorities are retained as UNC-style paths.
"""
if url.get_backend_name() != "sqlite":
return None

db_path = url.database
if not db_path or db_path == ":memory:":
return None

db_path = str(db_path)
query = {
str(key).lower(): str(value).strip().lower()
for key, value in dict(getattr(url, "query", {}) or {}).items()
}
uri_enabled = query.get("uri") in {"1", "true", "yes", "on"}
is_file_uri = db_path.lower().startswith("file:")

if not uri_enabled or not is_file_uri:
return db_path

if (
db_path.lower().startswith("file::memory:")
or query.get("mode") == "memory"
):
return None

parsed = urlparse(db_path)
fs_path = parsed.path or ""
if not fs_path or fs_path == ":memory:":
return None

authority = parsed.netloc
if authority and authority.lower() != "localhost":
fs_path = f"//{authority}{fs_path}"

return unquote(fs_path)
return _sqlite_db_path_impl(url)

# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.gpu-amd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
services:
odysseus:
build: .
security_opt:
- seccomp=./docker/seccomp/odysseus-bubblewrap.json
ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
volumes:
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.gpu-nvidia.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
services:
odysseus:
build: .
security_opt:
- seccomp=./docker/seccomp/odysseus-bubblewrap.json
ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
volumes:
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
services:
odysseus:
build: .
security_opt:
- seccomp=./docker/seccomp/odysseus-bubblewrap.json
ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
volumes:
Expand Down
Loading