Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02b6f-7db1-7222-918b-e813a6bda38d
284 lines
8.6 KiB
Python
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()
|