Files
odysseus/tests/test_tool_path_confinement.py
T
nopozandRaresKeY 934d23c0be Merge commit from fork
* 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.

* fix: enforce state deny during recursive file search

* fix: bound protected filesystem searches

* fix(security): reject inode aliases and workspace redirects

* fix(security): harden partitioned agent searches

* fix(security): report fallback worker exits promptly

* fix(security): clean up search readers and retain relative data roots

---------

Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
2026-09-05 19:21:12 +02:00

309 lines
11 KiB
Python

"""Regression tests for read_file / write_file path confinement.
Covers:
- /etc/shadow, /etc/passwd, /var/log — blocked (outside roots)
- ~/.ssh/authorized_keys — blocked (sensitive subpath deny list)
- Symlink that resolves into .ssh — blocked
- Relative traversal (~/../../etc/passwd) — blocked
- Shell rc files (.bashrc, .zshrc, .profile) — blocked
- SSH key filenames (id_rsa, id_ed25519) — blocked regardless of dir
- Legitimate paths under project data/ and /tmp — allowed
- Extra roots via tool_path_extra_roots setting — opt-in
- Even with $HOME as extra root, sensitive subpaths stay blocked
"""
import os
import sys
from types import SimpleNamespace
from unittest.mock import patch
import pytest
def _make_block(tool_type, content):
return SimpleNamespace(tool_type=tool_type, content=content)
# ── Unit tests on _is_sensitive_path ──────────────────────────────────
def test_sensitive_ssh_dir():
from src.tool_execution import _is_sensitive_path
assert _is_sensitive_path("/home/user/.ssh/authorized_keys")
assert _is_sensitive_path(os.path.expanduser("~") + "/.ssh/config")
def test_sensitive_gnupg_dir():
from src.tool_execution import _is_sensitive_path
assert _is_sensitive_path("/home/user/.gnupg/pubring.kbx")
def test_sensitive_shell_rc():
from src.tool_execution import _is_sensitive_path
assert _is_sensitive_path("/home/user/.bashrc")
assert _is_sensitive_path("/home/user/.zshrc")
assert _is_sensitive_path("/home/user/.profile")
def test_sensitive_key_filenames():
from src.tool_execution import _is_sensitive_path
assert _is_sensitive_path("/tmp/id_rsa")
assert _is_sensitive_path("/tmp/id_ed25519")
assert _is_sensitive_path("/tmp/authorized_keys")
def test_non_sensitive_path():
from src.tool_execution import _is_sensitive_path
assert not _is_sensitive_path("/tmp/notes.txt")
assert not _is_sensitive_path("/home/user/projects/file.py")
def test_sensitive_case_insensitive():
"""On case-insensitive filesystems (Windows, default macOS) a case-variant
name resolves to the same protected file, so the deny-list must match
regardless of case. Built with os.path.join so the separator is right on
both POSIX and Windows.
"""
from src.tool_execution import _is_sensitive_path
# sensitive directory, varied case
assert _is_sensitive_path(os.path.join("home", "u", ".SSH", "authorized_keys"))
assert _is_sensitive_path(os.path.join("home", "u", ".Gnupg", "pubring.kbx"))
# sensitive filename, varied case
assert _is_sensitive_path(os.path.join("ws", "AUTHORIZED_KEYS"))
assert _is_sensitive_path(os.path.join("ws", "Id_Rsa"))
assert _is_sensitive_path(os.path.join("ws", ".ENV"))
assert _is_sensitive_path(os.path.join("ws", ".Env"))
# both dir and file varied
assert _is_sensitive_path(os.path.join("home", "u", ".SSH", "AUTHORIZED_KEYS"))
# an ordinary file with none of the sensitive names is still allowed
assert not _is_sensitive_path(os.path.join("ws", "Readme.md"))
# ── Unit tests on _resolve_tool_path ─────────────────────────────────
def test_blocks_etc_shadow():
"""The motivating example: /etc/shadow must be rejected."""
from src.tool_execution import _resolve_tool_path
with pytest.raises(ValueError, match="outside the allowed roots"):
_resolve_tool_path("/etc/shadow")
def test_blocks_etc_passwd():
from src.tool_execution import _resolve_tool_path
with pytest.raises(ValueError, match="outside the allowed roots"):
_resolve_tool_path("/etc/passwd")
def test_blocks_var_log():
from src.tool_execution import _resolve_tool_path
with pytest.raises(ValueError, match="outside the allowed roots"):
_resolve_tool_path("/var/log/system.log")
def test_blocks_ssh_authorized_keys():
"""~/.ssh/authorized_keys — blocked by sensitive-subpath deny even
though $HOME is NOT a default root (the deny list fires first)."""
from src.tool_execution import _resolve_tool_path
with pytest.raises(ValueError, match="sensitive directory"):
_resolve_tool_path("~/.ssh/authorized_keys")
def test_blocks_ssh_dir_absolute():
from src.tool_execution import _resolve_tool_path
home = os.path.expanduser("~")
with pytest.raises(ValueError, match="sensitive directory"):
_resolve_tool_path(os.path.join(home, ".ssh", "config"))
def test_blocks_symlink_into_ssh(tmp_path):
"""A symlink under /tmp that points into ~/.ssh must be caught
because realpath resolves the link before the deny-list check."""
from src.tool_execution import _resolve_tool_path
ssh_dir = os.path.join(os.path.expanduser("~"), ".ssh")
os.makedirs(ssh_dir, exist_ok=True)
link = tmp_path / "ssh_link"
try:
link.symlink_to(ssh_dir)
except OSError:
pytest.skip("cannot create symlink")
with pytest.raises(ValueError, match="sensitive directory"):
_resolve_tool_path(str(link))
def test_blocks_traversal_outside_roots():
"""~/../../etc/passwd — after tilde expansion and .. resolution the
path lands outside every allowed root."""
from src.tool_execution import _resolve_tool_path
with pytest.raises(ValueError):
_resolve_tool_path("~/../../etc/passwd")
def test_blocks_bashrc():
from src.tool_execution import _resolve_tool_path
with pytest.raises(ValueError, match="sensitive directory"):
_resolve_tool_path("~/.bashrc")
def test_blocks_zshrc():
from src.tool_execution import _resolve_tool_path
with pytest.raises(ValueError, match="sensitive directory"):
_resolve_tool_path("~/.zshrc")
def test_blocks_env_file():
from src.tool_execution import _resolve_tool_path
with pytest.raises(ValueError, match="sensitive directory"):
_resolve_tool_path("~/.env")
def test_blocks_netrc():
from src.tool_execution import _resolve_tool_path
with pytest.raises(ValueError, match="sensitive directory"):
_resolve_tool_path("~/.netrc")
def test_allows_agent_workspace(tmp_path):
"""Paths under the agent's workspace in project data/ must resolve
cleanly. The rest of data/ is application state and is rejected;
tests/test_agent_state_dir_confinement.py covers that side."""
from src.tool_execution import _resolve_tool_path
from src.constants import AGENT_WORKSPACE_DIR
target = os.path.join(AGENT_WORKSPACE_DIR, "test-confinement-ok.txt")
os.makedirs(AGENT_WORKSPACE_DIR, exist_ok=True)
with open(target, "w") as f:
f.write("ok")
try:
resolved = _resolve_tool_path(target)
assert resolved == os.path.realpath(target)
finally:
os.unlink(target)
def test_allows_tmp(tmp_path):
"""Paths under /tmp (or its realpath) must resolve cleanly."""
from src.tool_execution import _resolve_tool_path
f = tmp_path / "confinement-test.txt"
f.write_text("ok")
resolved = _resolve_tool_path(str(f))
assert resolved == os.path.realpath(str(f))
def test_rejects_empty_path():
from src.tool_execution import _resolve_tool_path
with pytest.raises(ValueError, match="path is required"):
_resolve_tool_path("")
with pytest.raises(ValueError, match="path is required"):
_resolve_tool_path(" ")
def test_extra_roots_opt_in(tmp_path):
"""When tool_path_extra_roots includes a directory, paths under it
are allowed (but sensitive subpaths are still blocked)."""
from src.tool_execution import _resolve_tool_path
extra_dir = tmp_path / "extra_root"
extra_dir.mkdir()
target = extra_dir / "file.txt"
target.write_text("ok")
with patch("src.settings.get_setting", return_value=[str(extra_dir)]):
resolved = _resolve_tool_path(str(target))
assert resolved == os.path.realpath(str(target))
def test_extra_root_still_blocks_sensitive(tmp_path):
"""Even when $HOME is in tool_path_extra_roots, ~/.ssh/authorized_keys
must still be rejected by the sensitive-subpath deny list."""
from src.tool_execution import _resolve_tool_path
home = os.path.expanduser("~")
with patch("src.settings.get_setting", return_value=[home]):
with pytest.raises(ValueError, match="sensitive directory"):
_resolve_tool_path("~/.ssh/authorized_keys")
# ── Integration: dispatch-level tests ────────────────────────────────
@pytest.mark.asyncio
async def test_read_file_dispatch_blocks_etc_shadow(monkeypatch):
"""End-to-end: read_file dispatch must reject /etc/shadow."""
auth_mod = sys.modules.get("core.auth")
if auth_mod is None:
import core.auth as _real_auth
auth_mod = _real_auth
class _AdminAuth:
is_configured = True
def is_admin(self, username):
return True
monkeypatch.setattr(auth_mod, "AuthManager", lambda: _AdminAuth())
monkeypatch.setattr(
"src.tool_execution.owner_is_admin_or_single_user",
lambda owner: True,
)
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
desc, result = await execute_tool_block(
_make_block("read_file", "/etc/shadow"),
owner="admin-user",
security_context=NO_TOOL_SECURITY_CONTEXT,
)
assert "outside the allowed roots" in (result.get("error") or "")
assert result.get("exit_code") == 1
@pytest.mark.asyncio
async def test_write_file_dispatch_blocks_authorized_keys(monkeypatch):
"""End-to-end: write_file dispatch must reject ~/.ssh/authorized_keys."""
auth_mod = sys.modules.get("core.auth")
if auth_mod is None:
import core.auth as _real_auth
auth_mod = _real_auth
class _AdminAuth:
is_configured = True
def is_admin(self, username):
return True
monkeypatch.setattr(auth_mod, "AuthManager", lambda: _AdminAuth())
monkeypatch.setattr(
"src.tool_execution.owner_is_admin_or_single_user",
lambda owner: True,
)
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
desc, result = await execute_tool_block(
_make_block("write_file", "~/.ssh/authorized_keys\nssh-rsa AAAAB3..."),
owner="admin-user",
security_context=NO_TOOL_SECURITY_CONTEXT,
)
assert "sensitive directory" in (result.get("error") or "")
assert result.get("exit_code") == 1
@pytest.mark.asyncio
async def test_write_file_dispatch_blocks_cron(monkeypatch):
"""End-to-end: write_file to /etc/cron.d must be rejected."""
auth_mod = sys.modules.get("core.auth")
if auth_mod is None:
import core.auth as _real_auth
auth_mod = _real_auth
class _AdminAuth:
is_configured = True
def is_admin(self, username):
return True
monkeypatch.setattr(auth_mod, "AuthManager", lambda: _AdminAuth())
monkeypatch.setattr(
"src.tool_execution.owner_is_admin_or_single_user",
lambda owner: True,
)
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
desc, result = await execute_tool_block(
_make_block("write_file", "/etc/cron.d/agent-payload\n* * * * * root /tmp/p\n"),
owner="admin-user",
security_context=NO_TOOL_SECURITY_CONTEXT,
)
assert "outside the allowed roots" in (result.get("error") or "")
assert result.get("exit_code") == 1