Define repository grant contract
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02b6f-7db1-7222-918b-e813a6bda38d
This commit is contained in:
parent
ce8f56afea
commit
8cb004a558
8 changed files with 581 additions and 4 deletions
|
|
@ -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
59
docs/repository-grant.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
183
rein_aharness/repository_grant.py
Normal file
183
rein_aharness/repository_grant.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
284
tests/test_repository_grant.py
Normal file
284
tests/test_repository_grant.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue