89 lines
3.6 KiB
Python
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")
|