mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-12 18:02:20 +02:00
fix(security): keep agent file tools out of the app state directory
The agent's read tools (read_file, grep, glob, ls) resolved model-supplied
paths against a root list whose first entry was the whole data directory.
That directory holds the session store, the auth database, the app
encryption key and the settings file, so prompt-injected content could ask
for any of them. No approval prompt stood in the way: reads are classified
read_workspace and pass the untrusted-context gate untouched, which is
correct for reading a workspace and wrong for reading the app's own state.
The agent gets data/agent_workspace/ instead, and the subprocess cwd and
HOME move with it so bash and read_file agree on where scratch files live.
The deny itself is a property of the path, not of the root it arrived
through, because three routes reach the same bytes and closing only the
first leaves the other two working:
- the default root list
- a workspace bound at or above the data directory, which vet_workspace
accepted and chat_routes auto-binds from a path named in the message
- a tool_path_extra_roots setting covering the data directory
_resolve_search_root also returned the workspace root unchecked when the
path was empty, so a bare ls enumerated the directory whatever the deny
list said. It now resolves that case through the same guards.
A containment rule rather than a filename deny list, so state files added
later are covered without anyone remembering to list them, and so a user's
own settings.json or app.db inside a real workspace is not caught.
Four directories of user content stay readable, because the application
hands their paths to the model and tells it to open them: the chat upload
manifest, downloaded mail attachments, personal docs (which covers the
runbook) and personal uploads.
This commit is contained in:
@@ -5,7 +5,7 @@ import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
from src.constants import (
|
||||
DATA_DIR, PERSONAL_DIR, RUNBOOK_DIR, UPLOAD_DIR,
|
||||
DATA_DIR, PERSONAL_DIR, RUNBOOK_DIR, UPLOAD_DIR, AGENT_WORKSPACE_DIR,
|
||||
SESSIONS_FILE, DEFAULT_HOST, OPENAI_API_KEY
|
||||
)
|
||||
from src.memory import MemoryManager
|
||||
@@ -28,7 +28,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def create_directories():
|
||||
"""Create necessary directories if they don't exist."""
|
||||
for directory in (DATA_DIR, PERSONAL_DIR, RUNBOOK_DIR, UPLOAD_DIR):
|
||||
for directory in (DATA_DIR, PERSONAL_DIR, RUNBOOK_DIR, UPLOAD_DIR,
|
||||
AGENT_WORKSPACE_DIR):
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
def initialize_managers(base_dir: str, rag_manager=None) -> Dict[str, Any]:
|
||||
|
||||
@@ -54,6 +54,11 @@ GALLERY_DIR = os.path.join(DATA_DIR, "gallery")
|
||||
GALLERY_UPLOADS_DIR = os.path.join(DATA_DIR, "gallery_uploads")
|
||||
MEMORY_VECTORS_DIR = os.path.join(DATA_DIR, "memory_vectors")
|
||||
|
||||
# The only part of DATA_DIR the agent's file tools and subprocesses may touch.
|
||||
# Everything else under DATA_DIR is application state (session store, auth
|
||||
# database, encryption key, settings), and the agent has no business reading it.
|
||||
AGENT_WORKSPACE_DIR = os.path.join(DATA_DIR, "agent_workspace")
|
||||
|
||||
# Paths with an intentional dedicated env override, defaulting under DATA_DIR.
|
||||
MAIL_ATTACHMENTS_DIR = os.getenv("ODYSSEUS_MAIL_ATTACHMENTS_DIR", os.path.join(DATA_DIR, "mail-attachments"))
|
||||
# `or` (not os.getenv's default arg) so a PRESENT-but-EMPTY value falls back to
|
||||
|
||||
+119
-16
@@ -30,7 +30,12 @@ from src.tool_security import (
|
||||
from src.tool_capabilities import ToolRunSecurityContext, blocked_tool_result
|
||||
from src.tool_approvals import ExactToolApproval
|
||||
from src.tool_policy import ToolPolicy
|
||||
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES, DATA_DIR
|
||||
from src.constants import (
|
||||
MAX_OUTPUT_CHARS,
|
||||
MAX_READ_CHARS,
|
||||
MAX_DIFF_LINES,
|
||||
AGENT_WORKSPACE_DIR,
|
||||
)
|
||||
from src.tool_utils import _truncate, get_mcp_manager
|
||||
|
||||
|
||||
@@ -46,11 +51,11 @@ _MISSING_TOOL_SECURITY_CONTEXT = _MissingToolSecurityContext()
|
||||
NO_TOOL_SECURITY_CONTEXT = _NoToolSecurityContext()
|
||||
|
||||
# Persistent working directory for agent subprocesses.
|
||||
# Resolves to <repo_root>/data, which is the bind-mounted volume in Docker
|
||||
# (/app/data) and the local data directory for manual installs.
|
||||
# Using this as cwd and HOME prevents the agent from silently creating files
|
||||
# in ephemeral container layers that are lost on the next rebuild.
|
||||
_AGENT_WORKDIR = DATA_DIR
|
||||
# Resolves to <repo_root>/data/agent_workspace, inside the bind-mounted volume
|
||||
# in Docker (/app/data), so files survive a rebuild as before. The subdirectory
|
||||
# rather than data/ itself keeps agent scratch files and dotfiles out of the
|
||||
# directory holding the session store and the auth database.
|
||||
_AGENT_WORKDIR = AGENT_WORKSPACE_DIR
|
||||
|
||||
|
||||
|
||||
@@ -66,10 +71,15 @@ _AGENT_WORKDIR = DATA_DIR
|
||||
# 1. Sensitive-subpath deny list — checked FIRST. Blocks .ssh,
|
||||
# .gnupg, shell rc files, token/env files even if the root above
|
||||
# them is on the allowlist.
|
||||
# 2. Allowlist — only the directories the agent legitimately needs
|
||||
# (project data/, system tmp). $HOME is NOT on the default list.
|
||||
# 3. Opt-in extra roots — admin can add broader roots via the
|
||||
# "tool_path_extra_roots" setting (list of path strings).
|
||||
# 2. Application-state deny (_is_app_state_path) - DATA_DIR holds the
|
||||
# session store, auth database, app key and settings, so only
|
||||
# _agent_readable_data_subdirs() is readable inside it.
|
||||
# 3. Allowlist - only the directories the agent legitimately needs
|
||||
# (its data/ workspace, user content, system tmp). $HOME is NOT on
|
||||
# the default list.
|
||||
# 4. Opt-in extra roots - admin can add broader roots via the
|
||||
# "tool_path_extra_roots" setting. These cannot re-open DATA_DIR;
|
||||
# rule 2 is independent of which root a path arrived through.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SENSITIVE_BASENAMES: set[str] = {
|
||||
@@ -116,6 +126,84 @@ def _is_sensitive_path(resolved: str) -> bool:
|
||||
return filename in _SENSITIVE_FILE_PATTERNS_CF
|
||||
|
||||
|
||||
def _path_within(resolved: str, root: str) -> bool:
|
||||
"""True when *resolved* is *root* itself or sits underneath it.
|
||||
|
||||
Folds case before comparing, because the caller is a deny rule: a missed
|
||||
match here ALLOWS the path, where a missed match in the allowlist below
|
||||
only rejects it. On default macOS a case-variant name opens the same file
|
||||
and realpath does not canonicalise case, so an unfolded compare would let
|
||||
the state directory through. casefold rather than os.path.normcase for the
|
||||
reason given above _is_sensitive_path: normcase is a no-op on POSIX, which
|
||||
is exactly where that gap lives.
|
||||
"""
|
||||
resolved, root = resolved.casefold(), root.casefold()
|
||||
if resolved == root:
|
||||
return True
|
||||
try:
|
||||
return os.path.commonpath([resolved, root]) == root
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _agent_readable_data_subdirs() -> tuple[str, ...]:
|
||||
"""The only parts of DATA_DIR the agent's file tools may reach.
|
||||
|
||||
The agent's own scratch folder, plus the directories of user content whose
|
||||
paths the application itself gives to the model, which it would then be
|
||||
unable to open:
|
||||
|
||||
UPLOAD_DIR the chat upload manifest renders "path=<p>" and
|
||||
says to read it with read_file (agent_loop.py)
|
||||
MAIL_ATTACHMENTS_DIR download_attachment returns the path and its own
|
||||
description tells the model to read it
|
||||
PERSONAL_DIR GET /api/personal returns a path per file and is
|
||||
reachable through the app_api tool; RUNBOOK_DIR
|
||||
nests under it
|
||||
PERSONAL_UPLOADS_DIR indexed as a personal-docs directory, which
|
||||
manage_rag lists as an absolute path
|
||||
|
||||
Order matters: the first entry is roots[0], which _resolve_search_root uses
|
||||
when grep/glob/ls are called with no path.
|
||||
"""
|
||||
from src.constants import (
|
||||
MAIL_ATTACHMENTS_DIR,
|
||||
PERSONAL_DIR,
|
||||
PERSONAL_UPLOADS_DIR,
|
||||
UPLOAD_DIR,
|
||||
)
|
||||
return (
|
||||
AGENT_WORKSPACE_DIR,
|
||||
UPLOAD_DIR,
|
||||
MAIL_ATTACHMENTS_DIR,
|
||||
PERSONAL_DIR,
|
||||
PERSONAL_UPLOADS_DIR,
|
||||
)
|
||||
|
||||
|
||||
def _is_app_state_path(resolved: str) -> bool:
|
||||
"""True for anything under DATA_DIR that is not agent-readable.
|
||||
|
||||
DATA_DIR holds the session store, the auth database, the app encryption key
|
||||
and the settings file. A model-supplied path must not reach those through
|
||||
any root, so this is checked in both resolvers rather than expressed as an
|
||||
absence from the allowlist: a workspace bound at or above the data
|
||||
directory, or an opt-in tool_path_extra_roots entry covering it, would
|
||||
otherwise put them back in reach.
|
||||
|
||||
A containment rule rather than a filename deny list, so state files added
|
||||
later are covered without anyone remembering to list them, and so a user's
|
||||
own settings.json or app.db inside a real workspace is not caught.
|
||||
"""
|
||||
from src.constants import DATA_DIR
|
||||
if not _path_within(resolved, os.path.realpath(DATA_DIR)):
|
||||
return False
|
||||
return not any(
|
||||
_path_within(resolved, os.path.realpath(d))
|
||||
for d in _agent_readable_data_subdirs()
|
||||
)
|
||||
|
||||
|
||||
def _tool_path_roots() -> list[str]:
|
||||
"""Return the list of directory roots that read_file / write_file
|
||||
may touch. Default: project data/ + system temp dirs. Extra roots
|
||||
@@ -123,9 +211,9 @@ def _tool_path_roots() -> list[str]:
|
||||
"""
|
||||
roots: list[str] = []
|
||||
|
||||
# Project data directory — the agent's primary workspace.
|
||||
from src.constants import DATA_DIR
|
||||
roots.append(DATA_DIR)
|
||||
# The agent's workspace plus the user-content directories inside data/.
|
||||
# The rest of DATA_DIR is denied by _is_app_state_path.
|
||||
roots.extend(_agent_readable_data_subdirs())
|
||||
|
||||
# /tmp (and its macOS realpath /private/tmp).
|
||||
roots.append("/tmp")
|
||||
@@ -193,6 +281,10 @@ def _resolve_tool_path(raw_path: str) -> str:
|
||||
f"path '{raw_path}' is inside a sensitive directory "
|
||||
f"(e.g. .ssh, .gnupg) or matches a sensitive filename"
|
||||
)
|
||||
if _is_app_state_path(resolved):
|
||||
raise ValueError(
|
||||
f"path '{raw_path}' is inside the application state directory"
|
||||
)
|
||||
|
||||
for root in _tool_path_roots():
|
||||
if resolved == root:
|
||||
@@ -228,6 +320,10 @@ def _resolve_tool_path_in_workspace(workspace: str, raw_path: str) -> str:
|
||||
f"path '{raw_path}' is inside a sensitive directory "
|
||||
f"(e.g. .ssh, .gnupg) or matches a sensitive filename"
|
||||
)
|
||||
if _is_app_state_path(resolved):
|
||||
raise ValueError(
|
||||
f"path '{raw_path}' is inside the application state directory"
|
||||
)
|
||||
if resolved != base:
|
||||
# normcase so containment holds on case-insensitive filesystems
|
||||
# (Windows, default macOS): it lowercases on Windows and is a no-op on
|
||||
@@ -277,6 +373,10 @@ def vet_workspace(raw: str) -> Optional[str]:
|
||||
resolved = os.path.realpath(os.path.expanduser(raw))
|
||||
if not os.path.isdir(resolved) or _is_sensitive_path(resolved):
|
||||
return None
|
||||
# Refuse the bind rather than binding a workspace where every subsequent
|
||||
# tool call would fail on the same deny list.
|
||||
if _is_app_state_path(resolved):
|
||||
return None
|
||||
# Reject filesystem roots: binding / (or a Windows drive/UNC root) as the
|
||||
# workspace would make every absolute path "inside" it, collapsing the
|
||||
# confinement into host-wide file access. A root is its own dirname, which
|
||||
@@ -304,13 +404,16 @@ def _resolve_search_root(raw_path: str) -> str:
|
||||
|
||||
With a workspace active, the workspace folder is the root and a supplied
|
||||
path is confined inside it. Otherwise an empty path defaults to the agent's
|
||||
primary root (project data dir) and a supplied path is confined by the
|
||||
global allowlist + sensitive-file policy.
|
||||
primary root (its workspace under the project data dir) and a supplied path
|
||||
is confined by the global allowlist + sensitive-file policy.
|
||||
"""
|
||||
raw = (raw_path or "").strip()
|
||||
ws = get_active_workspace()
|
||||
if ws:
|
||||
return os.path.realpath(ws) if not raw else _resolve_tool_path_in_workspace(ws, raw)
|
||||
# Resolve the empty case as the workspace path rather than returning
|
||||
# it directly: returned unchecked it skipped both deny lists, so a
|
||||
# bare ls listed whatever the workspace was bound to.
|
||||
return _resolve_tool_path_in_workspace(ws, raw or ws)
|
||||
if not raw:
|
||||
roots = _tool_path_roots()
|
||||
return roots[0] if roots else os.path.realpath(".")
|
||||
|
||||
Reference in New Issue
Block a user