mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 18:22:20 +02:00
515 lines
20 KiB
Python
515 lines
20 KiB
Python
"""The agent's file tools must not reach the application's own state.
|
|
|
|
read_file / grep / glob / ls resolve model-supplied paths against
|
|
_tool_path_roots(), and the data directory holds the session store, the
|
|
credential database, the encryption key and the settings file. A read tool
|
|
pointed at those is a credential disclosure, and no approval prompt stands in
|
|
the way because reads are classified read_workspace and pass the untrusted-
|
|
context gate untouched.
|
|
|
|
The agent gets its own subdirectory instead. Three routes have to close
|
|
together, because closing only the first leaves the other two working:
|
|
|
|
- the default roots, which put DATA_DIR first
|
|
- an active workspace bound at (or above) the data directory
|
|
- a tool_path_extra_roots setting that covers the data directory
|
|
|
|
so the guard is a property of the path, not of the root it arrived through.
|
|
"""
|
|
|
|
import asyncio
|
|
import importlib
|
|
import os
|
|
import shutil
|
|
import time
|
|
from contextlib import contextmanager, nullcontext
|
|
|
|
import pytest
|
|
|
|
from src.constants import (
|
|
AGENT_WORKSPACE_DIR,
|
|
DATA_DIR,
|
|
MAIL_ATTACHMENTS_DIR,
|
|
PERSONAL_DIR,
|
|
PERSONAL_UPLOADS_DIR,
|
|
RUNBOOK_DIR,
|
|
UPLOAD_DIR,
|
|
)
|
|
from src.tool_execution import (
|
|
_active_workspace,
|
|
_resolve_search_root,
|
|
_resolve_tool_path,
|
|
agent_cwd,
|
|
vet_workspace,
|
|
)
|
|
from src.agent_tools.filesystem_tools import GlobTool, GrepTool, LsTool
|
|
|
|
APP_STATE_FILES = [
|
|
"sessions.json", # session token -> username, cleartext
|
|
"auth.json", # bcrypt hashes, admin flags, privileges
|
|
"app.db", # every user's notes, documents, mail rows
|
|
".app_key", # Fernet key for secret_storage
|
|
"settings.json", # provider API keys
|
|
]
|
|
|
|
|
|
@contextmanager
|
|
def workspace_at(path):
|
|
"""Bind an active workspace for the body of a test.
|
|
|
|
Set and reset in the same context; a ContextVar token cannot be reset from
|
|
fixture teardown, which runs in a different one.
|
|
"""
|
|
token = _active_workspace.set(os.path.realpath(path))
|
|
try:
|
|
yield
|
|
finally:
|
|
_active_workspace.reset(token)
|
|
|
|
|
|
# ── The default roots ────────────────────────────────────────────────
|
|
|
|
@pytest.mark.parametrize("name", APP_STATE_FILES)
|
|
def test_blocks_app_state_file(name):
|
|
with pytest.raises(ValueError, match="application state"):
|
|
_resolve_tool_path(os.path.join(DATA_DIR, name))
|
|
|
|
|
|
def test_blocks_listing_the_data_directory_itself():
|
|
"""`ls data` enumerated the state files, which is how an attacker who
|
|
does not know the install path finds them."""
|
|
with pytest.raises(ValueError, match="application state"):
|
|
_resolve_tool_path(DATA_DIR)
|
|
|
|
|
|
def test_blocks_app_state_reached_by_relative_path(monkeypatch):
|
|
"""The data directory is a relative hop from the checkout root, so
|
|
confinement cannot depend on the model supplying an absolute path."""
|
|
monkeypatch.chdir(os.path.dirname(DATA_DIR))
|
|
with pytest.raises(ValueError, match="application state"):
|
|
_resolve_tool_path(os.path.join(os.path.basename(DATA_DIR), "sessions.json"))
|
|
|
|
|
|
def test_blocks_app_state_reached_through_a_symlink(tmp_path):
|
|
"""/tmp is an allowed root and the agent can create links there in an
|
|
un-armed turn, so containment has to survive one."""
|
|
link = tmp_path / "shortcut"
|
|
try:
|
|
link.symlink_to(DATA_DIR)
|
|
except OSError:
|
|
pytest.skip("cannot create symlink")
|
|
with pytest.raises(ValueError, match="application state"):
|
|
_resolve_tool_path(str(link / "sessions.json"))
|
|
|
|
|
|
def test_blocks_app_state_on_a_case_insensitive_filesystem():
|
|
"""On default macOS a case-variant path opens the same file, and realpath
|
|
does not canonicalise case there the way it does on Windows.
|
|
|
|
This deny rule fails OPEN when containment misses, unlike the allowlist
|
|
beside it, which fails closed. So it folds case, for the same reason
|
|
_is_sensitive_path does and not with normcase, which is a no-op on POSIX.
|
|
"""
|
|
shouty = os.path.join(DATA_DIR.upper(), "SESSIONS.JSON")
|
|
with pytest.raises(ValueError, match="application state"):
|
|
_resolve_tool_path(shouty)
|
|
|
|
|
|
def test_default_search_root_is_the_agent_workspace():
|
|
"""grep/glob/ls with no path fall back to roots[0]. That was DATA_DIR."""
|
|
assert _resolve_search_root("") == os.path.realpath(AGENT_WORKSPACE_DIR)
|
|
|
|
|
|
def test_agent_workspace_is_inside_the_data_directory():
|
|
"""It has to stay under data/ to be covered by the Docker bind mount,
|
|
so the guard cannot simply be 'anything under DATA_DIR is denied'."""
|
|
assert os.path.realpath(AGENT_WORKSPACE_DIR).startswith(
|
|
os.path.realpath(DATA_DIR) + os.sep
|
|
)
|
|
|
|
|
|
# ── An active workspace ──────────────────────────────────────────────
|
|
|
|
def test_workspace_bound_at_the_data_directory_still_blocks_app_state():
|
|
"""vet_workspace() accepts the data directory, and chat_routes auto-binds
|
|
a workspace from a path named in the message, so this is reachable."""
|
|
with workspace_at(DATA_DIR):
|
|
with pytest.raises(ValueError, match="application state"):
|
|
_resolve_tool_path("sessions.json")
|
|
|
|
|
|
def test_workspace_bound_above_the_data_directory_still_blocks_app_state():
|
|
with workspace_at(os.path.dirname(DATA_DIR)):
|
|
with pytest.raises(ValueError, match="application state"):
|
|
_resolve_tool_path(os.path.join(DATA_DIR, "sessions.json"))
|
|
|
|
|
|
def test_workspace_bound_at_the_data_directory_refuses_the_empty_search_root():
|
|
"""grep/glob/ls with no path take the workspace itself as the root, which
|
|
skipped the in-workspace resolver and enumerated the state directory."""
|
|
with workspace_at(DATA_DIR):
|
|
with pytest.raises(ValueError, match="application state"):
|
|
_resolve_search_root("")
|
|
|
|
|
|
def test_vet_workspace_refuses_the_data_directory():
|
|
"""Rejecting the bind is the cleaner failure: the client is told the
|
|
workspace was refused instead of every tool call erroring separately."""
|
|
assert vet_workspace(DATA_DIR) is None
|
|
|
|
|
|
def test_vet_workspace_accepts_the_agent_workspace():
|
|
os.makedirs(AGENT_WORKSPACE_DIR, exist_ok=True)
|
|
assert vet_workspace(AGENT_WORKSPACE_DIR) == os.path.realpath(AGENT_WORKSPACE_DIR)
|
|
|
|
|
|
def test_workspace_bound_at_the_data_directory_still_allows_the_agent_workspace():
|
|
with workspace_at(DATA_DIR):
|
|
resolved = _resolve_tool_path(os.path.join("agent_workspace", "notes.txt"))
|
|
assert resolved == os.path.realpath(os.path.join(AGENT_WORKSPACE_DIR, "notes.txt"))
|
|
|
|
|
|
# ── An opt-in extra root ─────────────────────────────────────────────
|
|
|
|
def test_extra_root_covering_the_data_directory_still_blocks_app_state(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"src.settings.get_setting", lambda *_a, **_k: [os.path.dirname(DATA_DIR)]
|
|
)
|
|
with pytest.raises(ValueError, match="application state"):
|
|
_resolve_tool_path(os.path.join(DATA_DIR, "sessions.json"))
|
|
|
|
|
|
# ── What the agent keeps ─────────────────────────────────────────────
|
|
|
|
def test_allows_files_in_the_agent_workspace():
|
|
resolved = _resolve_tool_path(os.path.join(AGENT_WORKSPACE_DIR, "scratch.txt"))
|
|
assert resolved == os.path.realpath(os.path.join(AGENT_WORKSPACE_DIR, "scratch.txt"))
|
|
|
|
|
|
@pytest.mark.parametrize("directory, why", [
|
|
(UPLOAD_DIR,
|
|
"_uploaded_files_context_message emits path= and tells the model to "
|
|
"read it with read_file (src/agent_loop.py)"),
|
|
(MAIL_ATTACHMENTS_DIR,
|
|
"download_attachment returns the path and its description says to read "
|
|
"it with read_file (mcp_servers/email_server.py)"),
|
|
(PERSONAL_DIR,
|
|
"GET /api/personal returns a path per file and is reachable through the "
|
|
"app_api tool, which does not block that prefix"),
|
|
(PERSONAL_UPLOADS_DIR,
|
|
"indexed into personal docs by routes/personal_routes.py, and listed as "
|
|
"an absolute path by manage_rag"),
|
|
])
|
|
def test_allows_user_content_the_app_hands_to_the_model(directory, why):
|
|
"""Carving these out is not convenience. The app gives the model these
|
|
paths and tells it to read them, so denying them breaks the feature."""
|
|
target = os.path.join(directory, "example.txt")
|
|
assert _resolve_tool_path(target) == os.path.realpath(target), why
|
|
|
|
|
|
def test_runbook_is_covered_by_the_personal_docs_carve_out():
|
|
"""RUNBOOK_DIR nests under PERSONAL_DIR, so it needs no entry of its own."""
|
|
target = os.path.join(RUNBOOK_DIR, "notes.md")
|
|
assert _resolve_tool_path(target) == os.path.realpath(target)
|
|
|
|
|
|
def test_allows_tmp():
|
|
"""Unchanged: /tmp is still a root and holds no application state."""
|
|
assert _resolve_tool_path("/tmp/scratch.txt") == os.path.realpath("/tmp/scratch.txt")
|
|
|
|
|
|
def test_subprocess_cwd_is_the_agent_workspace():
|
|
"""bash/python cwd has to move with the file root, or the agent writes
|
|
where read_file can no longer look."""
|
|
assert agent_cwd() == os.path.realpath(AGENT_WORKSPACE_DIR)
|
|
|
|
|
|
def test_sensitive_deny_list_still_fires_inside_the_agent_workspace():
|
|
"""The new guard is layered on the existing one, not a replacement."""
|
|
with pytest.raises(ValueError, match="sensitive directory"):
|
|
_resolve_tool_path(os.path.join(AGENT_WORKSPACE_DIR, "id_rsa"))
|
|
|
|
|
|
# ── Misconfigured carve-outs and recursive traversal ────────────────
|
|
|
|
def _configure_test_data_tree(monkeypatch, data_dir):
|
|
current_constants = importlib.import_module("src.constants")
|
|
current_execution = importlib.import_module("src.tool_execution")
|
|
monkeypatch.setattr(current_constants, "DATA_DIR", str(data_dir), raising=False)
|
|
readable = {
|
|
"AGENT_WORKSPACE_DIR": data_dir / "agent_workspace",
|
|
"UPLOAD_DIR": data_dir / "uploads",
|
|
"MAIL_ATTACHMENTS_DIR": data_dir / "mail-attachments",
|
|
"PERSONAL_DIR": data_dir / "personal_docs",
|
|
"PERSONAL_UPLOADS_DIR": data_dir / "personal_uploads",
|
|
}
|
|
for name, path in readable.items():
|
|
monkeypatch.setattr(current_constants, name, str(path), raising=False)
|
|
monkeypatch.setattr(
|
|
current_execution,
|
|
"AGENT_WORKSPACE_DIR",
|
|
str(readable["AGENT_WORKSPACE_DIR"]),
|
|
)
|
|
return readable
|
|
|
|
|
|
@contextmanager
|
|
def current_workspace_at(path):
|
|
current_execution = importlib.import_module("src.tool_execution")
|
|
token = current_execution._active_workspace.set(os.path.realpath(path))
|
|
try:
|
|
yield
|
|
finally:
|
|
current_execution._active_workspace.reset(token)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_kind", ["equal", "ancestor", "root", "empty", "dot", "symlink"]
|
|
)
|
|
def test_invalid_readable_carveout_cannot_cancel_state_deny(
|
|
tmp_path, monkeypatch, bad_kind
|
|
):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
readable = _configure_test_data_tree(monkeypatch, data_dir)
|
|
bad = {
|
|
"equal": str(data_dir),
|
|
"ancestor": str(tmp_path),
|
|
"root": os.path.abspath(os.sep),
|
|
"empty": "",
|
|
"dot": ".",
|
|
}.get(bad_kind)
|
|
if bad_kind == "symlink":
|
|
link = tmp_path / "data-link"
|
|
try:
|
|
link.symlink_to(data_dir, target_is_directory=True)
|
|
except OSError:
|
|
pytest.skip("cannot create symlink")
|
|
bad = str(link)
|
|
current_execution = importlib.import_module("src.tool_execution")
|
|
monkeypatch.setattr(current_execution, "AGENT_WORKSPACE_DIR", bad)
|
|
secret = data_dir / "settings.json"
|
|
secret.write_text("STATE_SECRET\n", encoding="utf-8")
|
|
|
|
with pytest.raises(ValueError, match="application state"):
|
|
current_execution._resolve_tool_path(str(secret))
|
|
with pytest.raises(ValueError, match="default agent workspace"):
|
|
current_execution._resolve_search_root("")
|
|
assert os.path.realpath(readable["UPLOAD_DIR"]) in current_execution._tool_path_roots()
|
|
|
|
|
|
def test_recursive_glob_and_grep_hide_state_but_keep_readable_descendants(
|
|
tmp_path, monkeypatch
|
|
):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
readable = _configure_test_data_tree(monkeypatch, data_dir)
|
|
workspace = readable["AGENT_WORKSPACE_DIR"]
|
|
workspace.mkdir()
|
|
(workspace / "notes.json").write_text("SHARED_MARKER readable\n", encoding="utf-8")
|
|
(data_dir / "settings.json").write_text("SHARED_MARKER secret\n", encoding="utf-8")
|
|
|
|
with current_workspace_at(tmp_path):
|
|
glob_result = asyncio.run(GlobTool().execute(
|
|
'{"pattern": "**/*.json", "path": ""}', {}
|
|
))
|
|
grep_result = asyncio.run(GrepTool().execute(
|
|
'{"pattern": "SHARED_MARKER", "path": ""}', {}
|
|
))
|
|
|
|
assert "notes.json" in glob_result["output"]
|
|
assert "settings.json" not in glob_result["output"]
|
|
assert "notes.json" in grep_result["output"]
|
|
assert "settings.json" not in grep_result["output"]
|
|
|
|
|
|
def test_recursive_glob_and_grep_hide_state_from_extra_root(tmp_path, monkeypatch):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
readable = _configure_test_data_tree(monkeypatch, data_dir)
|
|
readable["AGENT_WORKSPACE_DIR"].mkdir()
|
|
(readable["AGENT_WORKSPACE_DIR"] / "public.txt").write_text(
|
|
"TOKEN visible\n", encoding="utf-8"
|
|
)
|
|
(data_dir / "auth.json").write_text("TOKEN hidden\n", encoding="utf-8")
|
|
monkeypatch.setattr("src.settings.get_setting", lambda *_a, **_k: [str(tmp_path)])
|
|
|
|
glob_result = asyncio.run(GlobTool().execute(
|
|
f'{{"pattern": "**/*", "path": "{tmp_path}"}}', {}
|
|
))
|
|
grep_result = asyncio.run(GrepTool().execute(
|
|
f'{{"pattern": "TOKEN", "path": "{tmp_path}"}}', {}
|
|
))
|
|
|
|
assert "public.txt" in glob_result["output"]
|
|
assert "auth.json" not in glob_result["output"]
|
|
assert "public.txt" in grep_result["output"]
|
|
assert "auth.json" not in grep_result["output"]
|
|
|
|
|
|
def test_existing_file_cannot_become_a_readable_directory_carveout(
|
|
tmp_path, monkeypatch
|
|
):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
_configure_test_data_tree(monkeypatch, data_dir)
|
|
secret = data_dir / "auth.json"
|
|
secret.write_text("STATE_SECRET\n", encoding="utf-8")
|
|
current_constants = importlib.import_module("src.constants")
|
|
monkeypatch.setattr(current_constants, "UPLOAD_DIR", str(secret))
|
|
current_execution = importlib.import_module("src.tool_execution")
|
|
|
|
assert (
|
|
os.path.realpath(secret)
|
|
not in current_execution._agent_readable_data_subdirs()
|
|
)
|
|
with pytest.raises(ValueError, match="application state"):
|
|
current_execution._resolve_tool_path(str(secret))
|
|
|
|
|
|
def test_external_mail_attachment_directory_remains_readable(tmp_path, monkeypatch):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
_configure_test_data_tree(monkeypatch, data_dir)
|
|
external = tmp_path / "external-mail"
|
|
external.mkdir()
|
|
attachment = external / "message.txt"
|
|
attachment.write_text("mail body\n", encoding="utf-8")
|
|
current_constants = importlib.import_module("src.constants")
|
|
monkeypatch.setattr(current_constants, "MAIL_ATTACHMENTS_DIR", str(external))
|
|
current_execution = importlib.import_module("src.tool_execution")
|
|
|
|
assert current_execution._resolve_tool_path(str(attachment)) == os.path.realpath(
|
|
attachment
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
os.path.normcase("DATA") == os.path.normcase("data"),
|
|
reason="requires a platform with case-sensitive path comparison",
|
|
)
|
|
def test_case_distinct_path_cannot_masquerade_as_readable_descendant(
|
|
tmp_path, monkeypatch
|
|
):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
_configure_test_data_tree(monkeypatch, data_dir)
|
|
distinct = tmp_path / "DATA" / "agent_workspace"
|
|
distinct.mkdir(parents=True)
|
|
current_execution = importlib.import_module("src.tool_execution")
|
|
monkeypatch.setattr(current_execution, "AGENT_WORKSPACE_DIR", str(distinct))
|
|
|
|
assert (
|
|
os.path.realpath(distinct)
|
|
not in current_execution._agent_readable_data_subdirs()
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("use_workspace", [True, False])
|
|
def test_ls_hides_protected_entries_when_root_contains_data(
|
|
tmp_path, monkeypatch, use_workspace
|
|
):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
_configure_test_data_tree(monkeypatch, data_dir)
|
|
(tmp_path / "visible.txt").write_text("visible\n", encoding="utf-8")
|
|
secret = data_dir / "settings.json"
|
|
secret.write_text("SECRET_WITH_SIZE\n", encoding="utf-8")
|
|
if use_workspace:
|
|
context = current_workspace_at(tmp_path)
|
|
content = '{"path": ""}'
|
|
else:
|
|
monkeypatch.setattr("src.settings.get_setting", lambda *_a, **_k: [str(tmp_path)])
|
|
context = nullcontext()
|
|
content = f'{{"path": "{tmp_path}"}}'
|
|
|
|
with context:
|
|
result = asyncio.run(LsTool().execute(content, {}))
|
|
|
|
assert "visible.txt" in result["output"]
|
|
assert "settings.json" not in result["output"]
|
|
assert "SECRET_WITH_SIZE" not in result["output"]
|
|
assert "data/" not in result["output"]
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("rg") is None, reason="requires ripgrep")
|
|
def test_state_spanning_grep_bounds_dangerous_regex(tmp_path, monkeypatch):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
readable = _configure_test_data_tree(monkeypatch, data_dir)
|
|
workspace = readable["AGENT_WORKSPACE_DIR"]
|
|
workspace.mkdir()
|
|
(workspace / "long.txt").write_text("a" * 250_000 + "!\n", encoding="utf-8")
|
|
(data_dir / "auth.txt").write_text("a" * 250_000 + "!\n", encoding="utf-8")
|
|
|
|
started = time.monotonic()
|
|
with current_workspace_at(tmp_path):
|
|
result = asyncio.run(GrepTool().execute(
|
|
'{"pattern": "(a+)+$", "path": "", "max_results": 1}', {}
|
|
))
|
|
elapsed = time.monotonic() - started
|
|
|
|
assert result["exit_code"] == 0
|
|
assert "auth.txt" not in result["output"]
|
|
assert elapsed < 5
|
|
|
|
|
|
def test_state_spanning_grep_stops_process_at_max_results(tmp_path, monkeypatch):
|
|
import subprocess
|
|
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
_configure_test_data_tree(monkeypatch, data_dir)
|
|
instances = []
|
|
|
|
class FakeProcess:
|
|
def __init__(self, *args, **kwargs):
|
|
self.stdout = iter(
|
|
f"{tmp_path}/visible-{index}.txt:1:MATCH\n" for index in range(100)
|
|
)
|
|
self.terminated = False
|
|
instances.append(self)
|
|
|
|
def poll(self):
|
|
return 0 if self.terminated else None
|
|
|
|
def terminate(self):
|
|
self.terminated = True
|
|
|
|
def wait(self, timeout=None):
|
|
return 0
|
|
|
|
def kill(self):
|
|
self.terminated = True
|
|
|
|
monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/rg")
|
|
monkeypatch.setattr(subprocess, "Popen", FakeProcess)
|
|
with current_workspace_at(tmp_path):
|
|
result = asyncio.run(GrepTool().execute(
|
|
'{"pattern": "MATCH", "path": "", "max_results": 1}', {}
|
|
))
|
|
|
|
assert len(instances) == 1
|
|
assert instances[0].terminated is True
|
|
assert result["output"].count(":1:MATCH") == 1
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("rg") is None, reason="requires ripgrep")
|
|
def test_state_spanning_grep_keeps_relative_glob_semantics(tmp_path, monkeypatch):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
readable = _configure_test_data_tree(monkeypatch, data_dir)
|
|
nested = readable["AGENT_WORKSPACE_DIR"] / "nested"
|
|
nested.mkdir(parents=True)
|
|
(nested / "readable.py").write_text("PATH_GLOB_MARKER\n", encoding="utf-8")
|
|
(data_dir / "protected.py").write_text("PATH_GLOB_MARKER\n", encoding="utf-8")
|
|
|
|
with current_workspace_at(tmp_path):
|
|
result = asyncio.run(GrepTool().execute(
|
|
'{"pattern": "PATH_GLOB_MARKER", "path": "", "glob": "**/*.py"}',
|
|
{},
|
|
))
|
|
|
|
assert "readable.py" in result["output"]
|
|
assert "protected.py" not in result["output"]
|