diff --git a/README.md b/README.md index a275969..d77626d 100644 --- a/README.md +++ b/README.md @@ -97,5 +97,6 @@ rein CLI details in an assignment. Instance manifest contract: [docs/instance-manifest.md](docs/instance-manifest.md). Example: [examples/schedule.harness.yml](examples/schedule.harness.yml). +Repository mutation authority: [docs/repository-grant.md](docs/repository-grant.md). Tests: `PYTHONPATH=".:$HOME/llm-connect" python3 -m pytest tests/ -q` diff --git a/docs/repository-grant.md b/docs/repository-grant.md new file mode 100644 index 0000000..94f8bcf --- /dev/null +++ b/docs/repository-grant.md @@ -0,0 +1,59 @@ +# Repository grant contract + +Status: **v1 parsed, validation-ready, not execution-enabled**. + +`repository_grant` is the explicit authority envelope for a bounded local Git +mutation. It is separate from task prose, labels, organizational attribution, +tool profiles, and repository path resolution. None of those inputs may be +interpreted as repository authority. + +## Version 1 + +```json +{ + "repository_grant": { + "version": "1", + "allowed_paths": ["docs/", "README.md"], + "commit_count": {"min": 1, "max": 1}, + "publish": false + } +} +``` + +All four fields are required and unknown fields are rejected. + +- `version` is the string `"1"`. +- `allowed_paths` is a non-empty array of unique, repository-relative POSIX + patterns. An exact path grants that path; a trailing `/` grants that directory + subtree; `*` is confined to one path segment; and `**` may span segments. + Absolute paths, parent traversal, backslashes, and `.git` grants are rejected. +- `commit_count.min` and `.max` are positive integers satisfying + `1 <= min <= max <= 32`. +- `publish` must be `false`. Version 1 grants local commits only. Publication + needs a future separately reviewed contract with remote/ref and outcome + evidence; it cannot be enabled by a label or tool profile. + +The parser canonicalizes path order and exposes a stable grant id plus bounded +evidence containing only the path count and digest, not the raw grant patterns. +The repository acceptance validator converts the grant into the policy used to +check descendant commits, changed paths, clean post-state, protected Git +metadata, and local remote-tracking refs. + +## Current fail-closed posture + +`TaskSpec.from_file` parses this contract, but `run_task` deliberately refuses +any task that supplies it before adapter dispatch. The Activity Core and +issue-core adapters do not currently populate it. Existing grant-absent direct +and compatibility runs retain their prior behavior while the transaction path +remains production-inert. + +Execution may be enabled only after: + +1. an authoritative Activity Core/profile field carries the reviewed grant; +2. the lease-bound transaction wraps adapter dispatch and result close; +3. the runner validates the accepted result against this exact grant; and +4. required close evidence durably records the grant, transaction, and accepted + result identities. + +Task descriptions, labels, `execution_refs`, consuming-repo defaults, and +profile absence must never synthesize a grant. diff --git a/docs/task-intake.md b/docs/task-intake.md index 5a11864..fa912f4 100644 --- a/docs/task-intake.md +++ b/docs/task-intake.md @@ -32,6 +32,11 @@ rein-aharness claim-loop `TaskExecutorWorkflow` in activity-core stays a stub; execution lives here. +Activity Core does not yet emit the versioned `repository_grant` required by +the lease-bound transaction path. The harness must not infer one from labels, +task prose, `execution_refs`, or target-repo lookup. See +**`docs/repository-grant.md`** for the parsed-but-not-enabled v1 contract. + --- ## Legacy / external: issue-core poll diff --git a/rein_aharness/repository_grant.py b/rein_aharness/repository_grant.py new file mode 100644 index 0000000..5b509ae --- /dev/null +++ b/rein_aharness/repository_grant.py @@ -0,0 +1,183 @@ +"""Versioned authority contract for repository mutation. + +The grant is parsed by TaskSpec but is not yet wired into run execution. A +supplied grant therefore causes run_task to refuse before adapter dispatch. +This keeps the contract reviewable without implying enforcement that the live +runner does not yet provide. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from typing import Any + +from rein_aharness.repository_transaction import RepositoryAcceptancePolicy + +REPOSITORY_GRANT_VERSION = "1" +_GRANT_KEYS = frozenset({"version", "allowed_paths", "commit_count", "publish"}) +_COMMIT_COUNT_KEYS = frozenset({"min", "max"}) + + +class RepositoryGrantError(ValueError): + """A repository grant is missing, ambiguous, or unsupported.""" + + +@dataclass(frozen=True) +class RepositoryGrant: + """One explicit, local-only repository mutation authority envelope.""" + + version: str + allowed_paths: tuple[str, ...] + min_commits: int + max_commits: int + publish: bool + + def __post_init__(self) -> None: + if self.version != REPOSITORY_GRANT_VERSION: + raise RepositoryGrantError( + f"unsupported version {self.version!r}; " + f"expected {REPOSITORY_GRANT_VERSION!r}" + ) + if ( + not isinstance(self.allowed_paths, tuple) + or not self.allowed_paths + or any(not isinstance(path, str) for path in self.allowed_paths) + ): + raise RepositoryGrantError( + "allowed_paths must be a non-empty canonical string tuple" + ) + if tuple(sorted(set(self.allowed_paths))) != self.allowed_paths: + raise RepositoryGrantError( + "allowed_paths must be unique and in canonical sorted order" + ) + _positive_int(self.min_commits, "min_commits") + _positive_int(self.max_commits, "max_commits") + if not isinstance(self.publish, bool): + raise RepositoryGrantError("publish must be a boolean") + if self.publish: + raise RepositoryGrantError( + "version 1 does not grant publication; publish must be false" + ) + try: + RepositoryAcceptancePolicy( + allowed_paths=self.allowed_paths, + min_commits=self.min_commits, + max_commits=self.max_commits, + ) + except ValueError as exc: + raise RepositoryGrantError(str(exc)) from exc + + @classmethod + def from_mapping(cls, value: Any) -> "RepositoryGrant": + if not isinstance(value, dict): + raise RepositoryGrantError("must be an object") + _require_exact_keys(value, _GRANT_KEYS, "repository_grant") + + version = value["version"] + if not isinstance(version, str) or version != REPOSITORY_GRANT_VERSION: + raise RepositoryGrantError( + f"unsupported version {version!r}; expected {REPOSITORY_GRANT_VERSION!r}" + ) + + raw_paths = value["allowed_paths"] + if not isinstance(raw_paths, list) or not raw_paths: + raise RepositoryGrantError("allowed_paths must be a non-empty array") + if any(not isinstance(path, str) for path in raw_paths): + raise RepositoryGrantError("allowed_paths entries must be strings") + if len(set(raw_paths)) != len(raw_paths): + raise RepositoryGrantError("allowed_paths must not contain duplicates") + allowed_paths = tuple(sorted(raw_paths)) + + raw_commits = value["commit_count"] + if not isinstance(raw_commits, dict): + raise RepositoryGrantError("commit_count must be an object") + _require_exact_keys(raw_commits, _COMMIT_COUNT_KEYS, "commit_count") + min_commits = _positive_int(raw_commits["min"], "commit_count.min") + max_commits = _positive_int(raw_commits["max"], "commit_count.max") + + publish = value["publish"] + if not isinstance(publish, bool): + raise RepositoryGrantError("publish must be a boolean") + if publish: + raise RepositoryGrantError( + "version 1 does not grant publication; publish must be false" + ) + + return cls( + version=version, + allowed_paths=allowed_paths, + min_commits=min_commits, + max_commits=max_commits, + publish=publish, + ) + + @property + def grant_id(self) -> str: + """Return a stable digest identifier without exposing granted paths.""" + return hashlib.sha256(self._canonical_json().encode("utf-8")).hexdigest()[:32] + + def acceptance_policy(self) -> RepositoryAcceptancePolicy: + """Translate the authority envelope into the local validator policy.""" + return RepositoryAcceptancePolicy( + allowed_paths=self.allowed_paths, + min_commits=self.min_commits, + max_commits=self.max_commits, + ) + + def evidence(self) -> dict[str, Any]: + """Return bounded, value-safe grant identity and authority facts.""" + path_payload = "\0".join(self.allowed_paths) + return { + "grant_id": self.grant_id, + "acceptance_policy_id": self.acceptance_policy().policy_id, + "version": self.version, + "allowed_path_count": len(self.allowed_paths), + "allowed_paths_digest": hashlib.sha256( + path_payload.encode("utf-8", errors="replace") + ).hexdigest(), + "min_commits": self.min_commits, + "max_commits": self.max_commits, + "publish": self.publish, + } + + def _canonical_json(self) -> str: + return json.dumps( + { + "allowed_paths": self.allowed_paths, + "commit_count": { + "max": self.max_commits, + "min": self.min_commits, + }, + "publish": self.publish, + "version": self.version, + }, + sort_keys=True, + separators=(",", ":"), + ) + + +def _require_exact_keys( + value: dict[str, Any], + expected: frozenset[str], + context: str, +) -> None: + if any(not isinstance(key, str) for key in value): + raise RepositoryGrantError(f"{context} field names must be strings") + actual = set(value) + missing = sorted(expected - actual) + unknown = sorted(actual - expected) + details: list[str] = [] + if missing: + details.append(f"missing: {', '.join(missing)}") + if unknown: + details.append(f"unknown: {', '.join(unknown)}") + if details: + raise RepositoryGrantError(f"{context} fields invalid ({'; '.join(details)})") + + +def _positive_int(value: Any, field_name: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise RepositoryGrantError(f"{field_name} must be a positive integer") + return value diff --git a/rein_aharness/runner.py b/rein_aharness/runner.py index f1ef908..86d1f5b 100644 --- a/rein_aharness/runner.py +++ b/rein_aharness/runner.py @@ -84,6 +84,22 @@ def run_task( tool_profile_override: str | None = None, budget_tokens_override: int | None = None, ) -> RunResult: + if spec.repository_grant is not None: + return RunResult( + ok=False, + committed=False, + head_before="", + head_after="", + persona_source="none", + session_output="", + reason=( + "refused: repository_grant enforcement is not enabled; " + "no adapter was dispatched" + ), + tool_profile="", + budget_tokens=None, + model=model, + ) try: profile_name, budget_tokens, lane, blueprint = resolve_run_policy( spec.target_repo, spec.agent diff --git a/rein_aharness/taskspec.py b/rein_aharness/taskspec.py index 1c99803..ad8d5b7 100644 --- a/rein_aharness/taskspec.py +++ b/rein_aharness/taskspec.py @@ -11,6 +11,8 @@ import json from dataclasses import dataclass, field from pathlib import Path +from rein_aharness.repository_grant import RepositoryGrant, RepositoryGrantError + class TaskSpecError(ValueError): pass @@ -26,6 +28,7 @@ class TaskSpec: hub_task_id: str | None = None completion_event_type: str = "executor_run" timeout_seconds: int = 900 + repository_grant: RepositoryGrant | None = None @classmethod def from_file(cls, path: str | Path) -> "TaskSpec": @@ -36,6 +39,12 @@ class TaskSpec: target = Path(raw["target_repo"]).expanduser() if not (target / ".git").is_dir(): raise TaskSpecError(f"target_repo is not a git repository: {target}") + grant = None + if "repository_grant" in raw: + try: + grant = RepositoryGrant.from_mapping(raw["repository_grant"]) + except RepositoryGrantError as exc: + raise TaskSpecError(f"invalid repository_grant: {exc}") from exc return cls( title=str(raw["title"]), description=str(raw["description"]), @@ -45,4 +54,5 @@ class TaskSpec: hub_task_id=raw.get("hub_task_id"), completion_event_type=str(raw.get("completion_event_type", "executor_run")), timeout_seconds=int(raw.get("timeout_seconds", 900)), + repository_grant=grant, ) diff --git a/tests/test_repository_grant.py b/tests/test_repository_grant.py new file mode 100644 index 0000000..0bc0ebb --- /dev/null +++ b/tests/test_repository_grant.py @@ -0,0 +1,284 @@ +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() diff --git a/workplans/HARNESS-WP-0003-governed-runtime-integrity.md b/workplans/HARNESS-WP-0003-governed-runtime-integrity.md index 84f6c97..1911752 100644 --- a/workplans/HARNESS-WP-0003-governed-runtime-integrity.md +++ b/workplans/HARNESS-WP-0003-governed-runtime-integrity.md @@ -244,10 +244,29 @@ unchanged `HEAD`, remote-ref movement, Git config/hook changes, and bounded path evidence. The full suite passes 123 tests with the existing optional `glas_harness` contract skip. -No current `TaskSpec` or instance-manifest field supplies an authoritative path -grant, so the validator remains deliberately unwired. Defining that versioned -grant surface, metrics atomicity, and required close-evidence reconciliation -remain outstanding; T03 remains `wait`. +No current Activity Core/profile field supplies an authoritative path grant, so +the validator remains deliberately unwired. Metrics atomicity and required +close-evidence reconciliation also remain outstanding; T03 remains `wait`. + +### Repository grant preparation — 2026-08-23 + +Added a parsed, versioned `repository_grant` v1 contract for `TaskSpec` inputs. +It requires explicit repository-relative path patterns, bounded positive commit +counts, `publish: false`, exact fields, and version `"1"`; it rejects unknown +fields, ambiguous types, duplicates, traversal/`.git` paths, and publication. +The canonical grant exposes a stable id and bounded path/policy digests and +translates directly to `RepositoryAcceptancePolicy`. + +This is fail-closed preparation, not production enablement. A supplied grant +causes `run_task` to refuse before profile resolution or adapter dispatch, and +the Activity Core/issue-core adapters do not synthesize it from labels, prose, +`execution_refs`, or repo lookup. Existing grant-absent compatibility behavior +is unchanged. `docs/repository-grant.md` records the contract and the remaining +activation gates: authoritative upstream carriage, lease-bound transaction +wiring, accepted-result validation, and durable close evidence. +Thirty-three focused cases cover canonical identity/evidence, validator translation, +schema/type/path/commit/publication refusal, typed task-file parsing, and proof +that the runner neither dispatches an adapter nor mutates the checkout. ## Remove tenant logic from the shared runtime