mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 09:45:24 -04:00
fix(tools): strict path confinement with sensitive-subpath deny list (#1072)
Rework read_file / write_file confinement after review feedback: - Remove $HOME from default allow roots. Only project data/ and system temp dirs are allowed out of the box. - Add a sensitive-subpath deny list (.ssh, .gnupg, shell rc files, .env, .netrc, SSH key filenames). Checked BEFORE allowlist so it blocks even when a broader root is configured. - Add "tool_path_extra_roots" setting for opt-in broader access. - Sensitive subpaths remain blocked regardless of configured roots. Tests: 24 cases covering /etc/shadow, ~/.ssh/authorized_keys, symlink into .ssh, traversal, shell rc files, key filenames, extra roots, and dispatch-level end-to-end.
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
"""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")
|
||||
|
||||
|
||||
# ── 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_project_data(tmp_path):
|
||||
"""Paths under project data/ must resolve cleanly."""
|
||||
from src.tool_execution import _resolve_tool_path
|
||||
from src.constants import DATA_DIR
|
||||
target = os.path.join(DATA_DIR, "test-confinement-ok.txt")
|
||||
os.makedirs(DATA_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 execute_tool_block
|
||||
desc, result = await execute_tool_block(
|
||||
_make_block("read_file", "/etc/shadow"),
|
||||
owner="admin-user",
|
||||
)
|
||||
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 execute_tool_block
|
||||
desc, result = await execute_tool_block(
|
||||
_make_block("write_file", "~/.ssh/authorized_keys\nssh-rsa AAAAB3..."),
|
||||
owner="admin-user",
|
||||
)
|
||||
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 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",
|
||||
)
|
||||
assert "outside the allowed roots" in (result.get("error") or "")
|
||||
assert result.get("exit_code") == 1
|
||||
Reference in New Issue
Block a user