from __future__ import annotations import json import subprocess from pathlib import Path import pytest from rein_aharness.repository_grant import RepositoryGrant, RepositoryGrantError from rein_aharness.repository_transaction import RepositoryTransaction from rein_aharness.runner import run_task from rein_aharness.taskspec import TaskSpec, TaskSpecError def _grant(**updates: object) -> dict[str, object]: value: dict[str, object] = { "version": "1", "allowed_paths": ["docs/", "README.md"], "commit_count": {"min": 1, "max": 1}, "publish": False, } value.update(updates) return value def _make_repo(tmp_path: Path) -> Path: repo = tmp_path / "repo" repo.mkdir() subprocess.run(["git", "init", "-q"], cwd=repo, check=True) (repo / "README.md").write_text("baseline\n", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=repo, check=True) subprocess.run( [ "git", "-c", "user.email=test@example.invalid", "-c", "user.name=test", "commit", "-qm", "baseline", ], cwd=repo, check=True, ) return repo def test_repository_grant_parses_to_acceptance_policy() -> None: grant = RepositoryGrant.from_mapping(_grant()) assert grant.version == "1" assert grant.allowed_paths == ("README.md", "docs/") assert grant.publish is False assert len(grant.grant_id) == 32 policy = grant.acceptance_policy() assert policy.allowed_paths == grant.allowed_paths assert policy.min_commits == 1 assert policy.max_commits == 1 def test_repository_grant_id_is_independent_of_path_order() -> None: first = RepositoryGrant.from_mapping(_grant()) second = RepositoryGrant.from_mapping( _grant(allowed_paths=["README.md", "docs/"]) ) assert first.grant_id == second.grant_id @pytest.mark.parametrize( "updates,match", [ ({"allowed_paths": ("../escape",)}, "repository-relative"), ({"allowed_paths": ("docs/", "README.md")}, "canonical sorted order"), ({"publish": True}, "does not grant publication"), ], ) def test_direct_repository_grant_construction_cannot_bypass_validation( updates: dict[str, object], match: str, ) -> None: values: dict[str, object] = { "version": "1", "allowed_paths": ("README.md", "docs/"), "min_commits": 1, "max_commits": 1, "publish": False, } values.update(updates) with pytest.raises(RepositoryGrantError, match=match): RepositoryGrant(**values) # type: ignore[arg-type] def test_repository_grant_evidence_is_bounded_and_omits_raw_paths() -> None: grant = RepositoryGrant.from_mapping(_grant()) evidence = grant.evidence() serialized = json.dumps(evidence, sort_keys=True) assert evidence["grant_id"] == grant.grant_id assert evidence["acceptance_policy_id"] == grant.acceptance_policy().policy_id assert evidence["allowed_path_count"] == 2 assert len(evidence["allowed_paths_digest"]) == 64 assert evidence["publish"] is False assert "README.md" not in serialized assert "docs/" not in serialized def test_repository_grant_drives_transaction_acceptance(tmp_path: Path) -> None: repo = _make_repo(tmp_path) grant = RepositoryGrant.from_mapping(_grant()) with RepositoryTransaction(repo, state_dir=tmp_path / "state") as transaction: docs = repo / "docs" docs.mkdir() (docs / "result.md").write_text("accepted\n", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=repo, check=True) subprocess.run( [ "git", "-c", "user.email=test@example.invalid", "-c", "user.name=test", "commit", "-qm", "accepted result", ], cwd=repo, check=True, ) accepted = transaction.validate_acceptance(grant.acceptance_policy()) assert accepted.changed_paths == ("docs/result.md",) assert accepted.policy_id == grant.acceptance_policy().policy_id @pytest.mark.parametrize( "value,match", [ (None, "must be an object"), ({}, "missing"), (_grant(extra="value"), "unknown: extra"), ({1: "not-a-field"}, "field names must be strings"), (_grant(version=1), "unsupported version"), (_grant(version="2"), "unsupported version"), ], ) def test_repository_grant_rejects_invalid_envelope( value: object, match: str, ) -> None: with pytest.raises(RepositoryGrantError, match=match): RepositoryGrant.from_mapping(value) @pytest.mark.parametrize( "paths,match", [ ([], "non-empty array"), ("docs/", "non-empty array"), (["docs/", 7], "entries must be strings"), (["docs/", "docs/"], "must not contain duplicates"), (["../escape"], "repository-relative"), ([".git/config"], "protected .git"), ], ) def test_repository_grant_rejects_ambiguous_or_unsafe_paths( paths: object, match: str, ) -> None: with pytest.raises(RepositoryGrantError, match=match): RepositoryGrant.from_mapping(_grant(allowed_paths=paths)) @pytest.mark.parametrize( "commit_count,match", [ (1, "must be an object"), ({"min": 1}, "missing: max"), ({"min": 1, "max": 1, "exact": 1}, "unknown: exact"), ({"min": True, "max": 1}, "min must be a positive integer"), ({"min": 0, "max": 1}, "min must be a positive integer"), ({"min": 2, "max": 1}, "commit bounds"), ({"min": 1, "max": 33}, "commit bounds"), ], ) def test_repository_grant_rejects_invalid_commit_bounds( commit_count: object, match: str, ) -> None: with pytest.raises(RepositoryGrantError, match=match): RepositoryGrant.from_mapping(_grant(commit_count=commit_count)) @pytest.mark.parametrize("publish", [True, "false", 0, None]) def test_repository_grant_rejects_publication_or_ambiguous_publish( publish: object, ) -> None: with pytest.raises(RepositoryGrantError, match="publish"): RepositoryGrant.from_mapping(_grant(publish=publish)) def test_taskspec_file_parses_typed_repository_grant(tmp_path: Path) -> None: repo = _make_repo(tmp_path) task_file = tmp_path / "task.json" task_file.write_text( json.dumps( { "title": "bounded change", "description": "update docs", "target_repo": str(repo), "repository_grant": _grant(), } ), encoding="utf-8", ) spec = TaskSpec.from_file(task_file) assert isinstance(spec.repository_grant, RepositoryGrant) assert spec.repository_grant.allowed_paths == ("README.md", "docs/") def test_taskspec_file_wraps_invalid_repository_grant(tmp_path: Path) -> None: repo = _make_repo(tmp_path) task_file = tmp_path / "task.json" task_file.write_text( json.dumps( { "title": "unsafe change", "description": "update anything", "target_repo": str(repo), "repository_grant": _grant(publish=True), } ), encoding="utf-8", ) with pytest.raises(TaskSpecError, match="invalid repository_grant"): TaskSpec.from_file(task_file) def test_runner_refuses_grant_before_adapter_dispatch_or_mutation(tmp_path: Path) -> None: repo = _make_repo(tmp_path) called = False class Adapter: def execute_prompt(self, prompt: str, config: object) -> None: nonlocal called called = True raise AssertionError("adapter must not be dispatched") head_before = _git(repo, "rev-parse", "HEAD") status_before = _git(repo, "status", "--porcelain=v2") result = run_task( TaskSpec( title="bounded change", description="update docs", target_repo=repo, repository_grant=RepositoryGrant.from_mapping(_grant()), ), adapter=Adapter(), report_to_hub=False, write_metrics=False, ) assert result.ok is False assert result.committed is False assert result.reason.startswith("refused: repository_grant enforcement") assert called is False assert _git(repo, "rev-parse", "HEAD") == head_before assert _git(repo, "status", "--porcelain=v2") == status_before def _git(repo: Path, *args: str) -> str: return subprocess.run( ["git", *args], cwd=repo, check=True, capture_output=True, text=True, ).stdout.strip()