Define repository grant contract

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02b6f-7db1-7222-918b-e813a6bda38d
This commit is contained in:
tegwick 2026-08-23 13:23:50 +02:00
parent ce8f56afea
commit 8cb004a558
8 changed files with 581 additions and 4 deletions

View file

@ -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`

59
docs/repository-grant.md Normal file
View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,
)

View file

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

View file

@ -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