rein-aharness/tests/test_repository_grant.py
tegwick 20e6f381f6 feat(runtime): enforce governed mutation boundaries
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a06ba0-10aa-7ea0-b20a-4f3fac39efe9
2026-09-04 11:25:07 +02:00

454 lines
14 KiB
Python

from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
from rein_aharness.metrics import external_metrics_dir
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_accepts_granted_commit_and_keeps_checkout_clean(tmp_path: Path) -> None:
repo = _make_repo(tmp_path)
class Adapter:
def execute_prompt(self, prompt: str, config: object):
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,
)
from llm_connect.models import LLMResponse
return LLMResponse(
content="done",
model="fake",
usage={},
finish_reason="stop",
)
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=True,
)
assert result.ok is True
assert result.committed is True
assert result.transaction is not None
assert result.transaction["acceptance"]["accepted"] is True
assert result.transaction["acceptance"]["changed_paths"] == ["docs/result.md"]
assert result.transaction["repository_grant"]["grant_id"]
assert result.transaction["metrics"] == {
"storage": "external",
"session_id": result.transaction["transaction_id"],
"projection_ready": True,
}
assert _git(repo, "status", "--porcelain=v2") == ""
assert not (repo / ".kaizen" / "metrics").exists()
metric_path = external_metrics_dir(repo, "coach") / "executions.jsonl"
metric_record = json.loads(metric_path.read_text(encoding="utf-8").strip())
assert metric_record["session_id"] == result.transaction["transaction_id"]
def test_runner_rejects_commit_outside_repository_grant(tmp_path: Path) -> None:
repo = _make_repo(tmp_path)
class Adapter:
def execute_prompt(self, prompt: str, config: object):
(repo / "UNRELATED.md").write_text("not granted\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",
"ungranted result",
],
cwd=repo,
check=True,
)
from llm_connect.models import LLMResponse
return LLMResponse(
content="done",
model="fake",
usage={},
finish_reason="stop",
)
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=True,
)
assert result.ok is False
assert result.committed is True
assert result.reason.startswith("repository acceptance failed: path-not-granted")
assert result.transaction is not None
assert "acceptance" not in result.transaction
def test_runner_refuses_grant_when_durable_metrics_are_disabled(
tmp_path: Path,
) -> None:
repo = _make_repo(tmp_path)
called = False
class Adapter:
def execute_prompt(self, prompt: str, config: object):
nonlocal called
called = True
raise AssertionError("adapter must not be dispatched")
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 "require durable external metrics" in result.reason
assert called is False
def test_runner_fails_granted_result_when_external_metrics_cannot_persist(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repo = _make_repo(tmp_path)
class Adapter:
def execute_prompt(self, prompt: str, config: object):
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,
)
from llm_connect.models import LLMResponse
return LLMResponse(
content="done",
model="fake",
usage={},
finish_reason="stop",
)
def fail_metrics(*args: object, **kwargs: object) -> None:
raise OSError("state volume unavailable")
monkeypatch.setattr(
"rein_aharness.runner.metrics.record_external_execution",
fail_metrics,
)
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=True,
)
assert result.ok is False
assert result.reason == "required external metrics persistence failed (OSError)"
assert result.transaction is not None
assert result.transaction["acceptance"]["accepted"] is True
assert _git(repo, "status", "--porcelain=v2") == ""
def _git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
).stdout.strip()