rein-aharness/tests/test_repository_grant.py
tegwick 8cb004a558 Define repository grant contract
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02b6f-7db1-7222-918b-e813a6bda38d
2026-08-23 13:23:50 +02:00

284 lines
8.6 KiB
Python

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()