Files
aios/tests/v2r1/test_hermes_profile.py
T

89 lines
3.6 KiB
Python

"""Executable completeness contract for Hermes profile isolation."""
from __future__ import annotations
from pathlib import Path
import os
import pytest
from aios_v2r1.hermes_profile import HermesProfile
from aios_v2r1.tenant_context import ValidationError
def profile(tmp_path: Path, **changes: object) -> HermesProfile:
values: dict[str, object] = {
"tenant_id": "tenant-a", "cell_id": "cell-a", "profile_id": "profile-a",
"hermes_home": tmp_path / "home", "workspace": tmp_path / "workspace"}
values.update(changes)
return HermesProfile(**values) # type: ignore[arg-type]
def test_environment_is_exact_and_has_no_inherited_values(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("HOST_SECRET", "must-not-inherit")
value = profile(tmp_path).environment
assert value == {
"HERMES_HOME": str(tmp_path / "home"), "HOME": str(tmp_path / "home"),
"AIOS_TENANT_ID": "tenant-a", "AIOS_CELL_ID": "cell-a",
"AIOS_PROFILE_ID": "profile-a"}
assert "HOST_SECRET" not in value
@pytest.mark.parametrize("changes", [
{"hermes_home": Path("relative-home")},
{"workspace": Path("relative-workspace")}])
def test_profile_roots_must_be_absolute(
tmp_path: Path, changes: dict[str, Path]
) -> None:
with pytest.raises(ValidationError, match="absolute"):
profile(tmp_path, **changes)
def test_prepare_sets_both_roots_to_0700(tmp_path: Path) -> None:
value = profile(tmp_path)
value.prepare()
assert os.stat(value.hermes_home).st_mode & 0o777 == 0o700
assert os.stat(value.workspace).st_mode & 0o777 == 0o700
def test_prepare_is_idempotent(tmp_path: Path) -> None:
value = profile(tmp_path)
value.prepare()
marker = value.workspace / "marker"
marker.write_text("kept", encoding="utf-8")
value.prepare()
assert marker.read_text(encoding="utf-8") == "kept"
def test_parent_traversal_is_denied(tmp_path: Path) -> None:
value = profile(tmp_path)
value.prepare()
with pytest.raises(ValidationError, match="escapes"):
value.assert_path_allowed(value.workspace / ".." / "outside")
def test_symlink_inside_profile_cannot_escape(tmp_path: Path) -> None:
value = profile(tmp_path)
value.prepare()
outside = tmp_path / "outside"
outside.mkdir()
(value.workspace / "link").symlink_to(outside, target_is_directory=True)
with pytest.raises(ValidationError, match="escapes"):
value.assert_path_allowed(value.workspace / "link" / "secret")
@pytest.mark.parametrize("root_name", ["home", "workspace"])
def test_prepare_rejects_symlink_roots(tmp_path: Path, root_name: str) -> None:
outside = tmp_path / f"outside-{root_name}"
outside.mkdir()
symlink = tmp_path / root_name
symlink.symlink_to(outside, target_is_directory=True)
changes = {"hermes_home" if root_name == "home" else "workspace": symlink}
with pytest.raises(ValidationError, match="symlink"):
profile(tmp_path, **changes).prepare()
def test_other_profile_scope_is_denied(tmp_path: Path) -> None:
first = profile(tmp_path / "first")
second = profile(tmp_path / "second", tenant_id="tenant-b",
cell_id="cell-b", profile_id="profile-b")
first.prepare()
second.prepare()
with pytest.raises(ValidationError, match="escapes"):
first.assert_path_allowed(second.workspace / "data")
def test_paths_within_either_root_are_allowed(tmp_path: Path) -> None:
value = profile(tmp_path)
value.prepare()
value.assert_path_allowed(value.hermes_home / "config.json")
value.assert_path_allowed(value.workspace / "task.txt")