Skip to content
Draft
12 changes: 11 additions & 1 deletion THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se

**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.

### 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. The temporary blanket gate that armed after any untrusted tool result is disabled; mode-owned authority checks remain active.

- **Sandbox (default):** process execution stays inside the workspace sandbox. Brokered public reads, private/workspace operations, and contained execution can continue without a new approval merely because earlier context was untrusted. Unknown tools, arbitrary network egress, external side effects, admin changes, and destructive actions still require an exact approval.
- **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.
- **Ask (not exposed in the current selector):** uses the workspace sandbox and requires an exact approval for every risky action.

Exact approvals are opaque, expiring, one-use server records bound to the owner, session, origin run, exact tool name and input, workspace, security mode, effect classification, and external-context state. The browser submits only the opaque approval ID and the user's approve/deny decision. The separate review step for saving a teacher-generated reusable skill remains active.

## Security Headers

`core/middleware.py:SecurityHeadersMiddleware` sets headers on every response:
Expand All @@ -72,7 +82,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 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 sandbox's private PID/network policy; 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.

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
129 changes: 61 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 Expand Up @@ -220,6 +159,7 @@ class Session(TimestampMixin, Base):
total_input_tokens = Column(Integer, default=0)
total_output_tokens = Column(Integer, default=0)
mode = Column(String, nullable=True) # 'agent', 'chat', or 'research'
security_mode = Column(String, nullable=False, default="sandbox")
crew_member_id = Column(String, nullable=True) # links to crew_members.id

# Relationship to chat messages
Expand Down Expand Up @@ -249,6 +189,8 @@ def to_dict(self):
'total_input_tokens': self.total_input_tokens or 0,
'total_output_tokens': self.total_output_tokens or 0,
'crew_member_id': self.crew_member_id,
'mode': self.mode,
'security_mode': self.security_mode or 'sandbox',
}

class ChatMessage(Base):
Expand Down Expand Up @@ -1259,6 +1201,26 @@ def _migrate_add_mode_column():
except Exception:
pass

def _migrate_add_security_mode_column():
"""Add the fail-safe agent run mode to existing session databases."""
try:
inspector = inspect(engine)
if "sessions" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("sessions")}
if "security_mode" not in columns:
with engine.begin() as conn:
conn.execute(text(
"ALTER TABLE sessions ADD COLUMN security_mode VARCHAR "
"NOT NULL DEFAULT 'sandbox'"
))
logging.getLogger(__name__).info(
"Migrated: added 'security_mode' column to sessions"
)
except Exception as e:
logging.getLogger(__name__).warning(
f"Migration check for security_mode failed: {e}"
)
def _migrate_add_folder_column():
"""Add folder column to sessions table if it doesn't exist."""
import sqlite3
Expand Down Expand Up @@ -2116,6 +2078,7 @@ def init_db():
_migrate_add_folder_column()
_migrate_add_token_columns()
_migrate_add_mode_column()
_migrate_add_security_mode_column()
_migrate_add_multiuser_owner_columns()
_migrate_add_gallery_caption_column()
_migrate_add_api_token_scopes_column()
Expand Down Expand Up @@ -2690,6 +2653,36 @@ def set_session_mode(session_id: str, mode: str) -> bool:
logger.warning("Failed to persist mode %r for session %s", mode, session_id)
return False

def get_session_security_mode(session_id: str) -> str:
"""Return the persisted agent authority mode, defaulting safely."""
try:
with get_db_session() as db:
value = db.query(Session.security_mode).filter(
Session.id == session_id
).scalar()
return value if value in {"ask", "sandbox", "full_access"} else "sandbox"
except Exception:
logger.warning("Failed to read security mode for session %s", session_id)
return "sandbox"

def set_session_security_mode(session_id: str, mode: str) -> bool:
"""Persist a validated agent authority mode; invalid values fail closed."""
if mode not in {"ask", "sandbox", "full_access"}:
return False
try:
with get_db_session() as db:
db.query(Session).filter(Session.id == session_id).update(
{"security_mode": mode}
)
return True
except Exception:
logger.warning(
"Failed to persist security mode %r for session %s",
mode,
session_id,
)
return False

def get_session_by_id(session_id: str):
"""Get a session by ID"""
with get_db_session() as db:
Expand Down
1 change: 1 addition & 0 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class Session:
owner: Optional[str] = None
is_important: bool = False
message_count: int = 0
security_mode: str = "sandbox"

def __post_init__(self):
if self.headers is None:
Expand Down
7 changes: 7 additions & 0 deletions core/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ def _db_to_session_meta(self, db_session: DbSession) -> Optional[Session]:
history=[],
owner=getattr(db_session, "owner", None),
is_important=getattr(db_session, "is_important", False) or False,
security_mode=getattr(db_session, "security_mode", None) or "sandbox",
)
session.message_count = getattr(db_session, "message_count", 0) or 0
return session
Expand Down Expand Up @@ -208,6 +209,7 @@ def _db_to_session(self, db_session: DbSession, db) -> Optional[Session]:
history=history,
owner=getattr(db_session, 'owner', None),
is_important=getattr(db_session, 'is_important', False) or False,
security_mode=getattr(db_session, "security_mode", None) or "sandbox",
)

# The rows just loaded are the whole transcript, so they — not the
Expand Down Expand Up @@ -490,6 +492,9 @@ def sync_session_metadata(self, session_id: str) -> bool:
.filter(DbChatMessage.session_id == session_id)
.count()
)
session.security_mode = (
getattr(db_session, "security_mode", None) or "sandbox"
)
return True
except Exception as e:
logger.error(f"Error syncing session metadata {session_id}: {e}")
Expand Down Expand Up @@ -558,6 +563,7 @@ def create_session(
rag=rag,
headers={},
owner=owner,
security_mode="sandbox",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
Expand All @@ -572,6 +578,7 @@ def create_session(
rag=rag,
headers={},
owner=owner,
security_mode="sandbox",
)

self.sessions[session_id] = session
Expand Down
68 changes: 67 additions & 1 deletion routes/chat_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,18 @@
)
from src.session_search import search_session_messages
from src.prompt_security import untrusted_context_message
from src.execution_sandbox import network_profile_for_internet_preference
from core.exceptions import SessionNotFoundError
from src.auth_helpers import effective_user, get_current_user
from routes.session_routes import _verify_session_owner
from routes.document_helpers import _owner_session_filter
from core.database import SessionLocal, get_session_mode, set_session_mode
from core.database import (
SessionLocal,
get_session_mode,
get_session_security_mode,
set_session_mode,
set_session_security_mode,
)
from core.database import Session as DBSession, ChatMessage as DBChatMessage
from core.database import Document as DBDocument, ModelEndpoint
from core.log_safety import redact_url
Expand All @@ -68,6 +75,8 @@
web_search_enabled_for_turn,
)
from src.tool_approvals import tool_approval_store
from src.agent_run_policy import AgentRunMode, parse_agent_run_mode
from src.tool_security import owner_is_admin_or_single_user

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -906,6 +915,10 @@ async def chat_stream(request: Request) -> StreamingResponse:
incognito = str(form_data.get("incognito", "")).lower() == "true"
plan_mode = str(form_data.get("plan_mode") or (body or {}).get("plan_mode") or "").lower() == "true"
chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent'
requested_security_mode = (
form_data.get("security_mode")
or (body or {}).get("security_mode")
)
tool_approval_id = (
form_data.get("tool_approval_id")
or (body or {}).get("tool_approval_id")
Expand Down Expand Up @@ -1064,6 +1077,35 @@ async def chat_stream(request: Request) -> StreamingResponse:
_verify_session_owner(request, session)
sess = session_manager.get_session(session)
owner = effective_user(request)
persisted_security_mode = (
getattr(sess, "security_mode", None)
or get_session_security_mode(session)
)
if requested_security_mode not in (None, ""):
requested_security_mode = str(requested_security_mode).strip().lower()
if requested_security_mode not in {
AgentRunMode.ASK.value,
AgentRunMode.SANDBOX.value,
AgentRunMode.FULL_ACCESS.value,
}:
raise HTTPException(400, "Invalid agent security mode.")
effective_security_mode = parse_agent_run_mode(
requested_security_mode
)
else:
effective_security_mode = parse_agent_run_mode(
persisted_security_mode
)
if (
effective_security_mode is AgentRunMode.FULL_ACCESS
and not owner_is_admin_or_single_user(owner)
):
if requested_security_mode not in (None, ""):
raise HTTPException(
403,
"Full access agent mode requires an admin user.",
)
effective_security_mode = AgentRunMode.SANDBOX
if tool_approval_id:
pending_tool_approval = tool_approval_store.peek(tool_approval_id)
normalized_owner = str(owner or "").strip().casefold()
Expand All @@ -1084,6 +1126,14 @@ async def chat_stream(request: Request) -> StreamingResponse:
409,
"Tool approvals cannot be consumed while plan mode is active.",
)
if (
pending_tool_approval.security_mode
!= effective_security_mode.value
):
raise HTTPException(
409,
"The thread security state changed; review the action again.",
)
exact_tool_approval = tool_approval_store.consume(
tool_approval_id,
decision=decision,
Expand Down Expand Up @@ -1497,6 +1547,12 @@ async def chat_stream(request: Request) -> StreamingResponse:
_effective_mode = 'research' if effective_do_research else (chat_mode or 'chat')
if _effective_mode in ('agent', 'research', 'chat'):
set_session_mode(session, _effective_mode)
if (
requested_security_mode not in (None, "")
or effective_security_mode.value != persisted_security_mode
):
set_session_security_mode(session, effective_security_mode.value)
sess.security_mode = effective_security_mode.value

async def stream_with_save() -> AsyncGenerator[str, None]:
# _effective_mode is read-only here; closure captures it from
Expand Down Expand Up @@ -2198,6 +2254,14 @@ def _commit_chat_compaction(candidate_index: int) -> bool:
elif _explicit_browser_intent:
_forced_tools = set(_BROWSER_MCP_TOOLS)

# For now, Bubblewrap networking follows the existing user
# web toggle. This mapping may change in the future; keep
# every other toggle's behavior unchanged for now. The
# server snapshots a BROKERED_ONLY or NETWORKLESS profile;
# Sandbox mode never exposes the raw container namespace.
_sandbox_network_profile = network_profile_for_internet_preference(
_search_enabled
)
async for chunk in stream_agent_loop(
sess.endpoint_url,
sess.model,
Expand Down Expand Up @@ -2235,6 +2299,8 @@ def _commit_chat_compaction(candidate_index: int) -> bool:
)
),
exact_approval=exact_tool_approval,
security_mode=effective_security_mode.value,
network_profile=_sandbox_network_profile,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
Expand Down
Loading
Loading