feat: reconcile granted ops run closes
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a06bfe-2a55-7ed3-bacd-879977b099bf
This commit is contained in:
parent
b72fdb5452
commit
b63131e863
21 changed files with 1240 additions and 43 deletions
|
|
@ -86,6 +86,35 @@ def test_action_carries_declared_profile_and_renders_attribution_refs() -> None:
|
|||
}
|
||||
|
||||
|
||||
def test_action_carries_repository_grant_without_rendering_authority() -> None:
|
||||
grant = {
|
||||
"version": "1",
|
||||
"allowed_paths": ["docs/{context.request.scope}", "README.md"],
|
||||
"commit_count": {"min": 1, "max": 1},
|
||||
"publish": False,
|
||||
}
|
||||
rules = [
|
||||
{
|
||||
"id": "granted-task",
|
||||
"condition": "",
|
||||
"action": {
|
||||
"task_template": "Run controlled task",
|
||||
"target_repo": "activity-core",
|
||||
"repository_grant": grant,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
specs = expand_rule_actions(
|
||||
rules,
|
||||
_Event(),
|
||||
{"request": {"scope": "must-not-be-authority"}},
|
||||
)
|
||||
|
||||
assert specs[0]["repository_grant"] == grant
|
||||
assert specs[0]["repository_grant"] is not grant
|
||||
|
||||
|
||||
def test_for_each_binds_each_list_item_before_condition_and_action_rendering() -> None:
|
||||
rules = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import json
|
|||
from activity_core.glas_evidence import (
|
||||
normalise_execution_evidence,
|
||||
normalise_ops_result,
|
||||
normalise_repository_transaction,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -113,3 +114,61 @@ def test_keeps_legacy_artifact_fields_but_not_unknown_blobs() -> None:
|
|||
{"kind": "report", "label": "Report", "url": "https://example.test/a"}
|
||||
]
|
||||
assert "messages" not in result
|
||||
|
||||
|
||||
def test_preserves_bounded_repository_transaction_identity() -> None:
|
||||
transaction = normalise_repository_transaction(
|
||||
{
|
||||
"transaction_id": "tx-1",
|
||||
"correlation_id": "run-1",
|
||||
"baseline": {"repo_id": "repo-1", "head": "base", "secret": "drop"},
|
||||
"repository_grant": {
|
||||
"grant_id": "grant-1",
|
||||
"acceptance_policy_id": "policy-1",
|
||||
"version": "1",
|
||||
"allowed_path_count": 2,
|
||||
"allowed_paths_digest": "digest",
|
||||
"min_commits": 1,
|
||||
"max_commits": 1,
|
||||
"publish": False,
|
||||
"allowed_paths": ["must-not-persist"],
|
||||
},
|
||||
"acceptance": {
|
||||
"policy_id": "policy-1",
|
||||
"accepted": True,
|
||||
"head": "result",
|
||||
"commit_count": 1,
|
||||
"commits": ["result"],
|
||||
"changed_path_count": 1,
|
||||
"changed_paths": ["docs/result.md"] * 101,
|
||||
"changed_paths_digest": "paths-digest",
|
||||
"clean_post_state": True,
|
||||
"provider_blob": "drop",
|
||||
},
|
||||
"metrics": {
|
||||
"storage": "external",
|
||||
"session_id": "tx-1",
|
||||
"projection_ready": True,
|
||||
},
|
||||
"tool_output": "drop",
|
||||
}
|
||||
)
|
||||
|
||||
assert transaction["transaction_id"] == "tx-1"
|
||||
assert transaction["repository_grant"]["grant_id"] == "grant-1"
|
||||
assert transaction["acceptance"]["accepted"] is True
|
||||
assert len(transaction["acceptance"]["changed_paths"]) == 100
|
||||
assert transaction["metrics"]["session_id"] == "tx-1"
|
||||
assert "allowed_paths" not in transaction["repository_grant"]
|
||||
assert "tool_output" not in transaction
|
||||
assert "secret" not in transaction["baseline"]
|
||||
|
||||
|
||||
def test_ops_result_accepts_repository_transaction_alias() -> None:
|
||||
result = normalise_ops_result(
|
||||
{"transaction": {"transaction_id": "tx-alias", "unknown": "drop"}}
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"repository_transaction": {"transaction_id": "tx-alias"}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,6 +242,47 @@ async def test_emit_tasks_refuses_absent_profile_in_strict_mode(monkeypatch) ->
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_tasks_refuses_invalid_grant_before_any_side_effect(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from temporalio.exceptions import ApplicationError
|
||||
|
||||
monkeypatch.setattr(
|
||||
activities,
|
||||
"get_issue_sink",
|
||||
lambda: pytest.fail("IssueSink must not be opened after grant refusal"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
activities,
|
||||
"_get_session_factory",
|
||||
lambda: pytest.fail("DB must not be opened after grant refusal"),
|
||||
)
|
||||
|
||||
with pytest.raises(ApplicationError, match="repository grant refused") as exc_info:
|
||||
await activities.emit_tasks(
|
||||
{
|
||||
"activity_id": "00000000-0000-0000-0000-000000000001",
|
||||
"triggering_event_id": "event-1",
|
||||
"task_specs": [
|
||||
{
|
||||
"title": "Must not emit",
|
||||
"source_type": "rule",
|
||||
"source_id": "unsafe-grant",
|
||||
"repository_grant": {
|
||||
"version": "1",
|
||||
"allowed_paths": ["../escape"],
|
||||
"commit_count": {"min": 1, "max": 1},
|
||||
"publish": False,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert exc_info.value.non_retryable is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_tasks_passes_unknown_versioned_profile_to_queue(monkeypatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
|
@ -297,6 +338,12 @@ async def test_emit_tasks_passes_unknown_versioned_profile_to_queue(monkeypatch)
|
|||
"correlation_id": "corr-9",
|
||||
"api_key": "must-be-dropped",
|
||||
},
|
||||
"repository_grant": {
|
||||
"version": "1",
|
||||
"allowed_paths": ["docs/", "README.md"],
|
||||
"commit_count": {"min": 1, "max": 1},
|
||||
"publish": False,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
|
@ -306,3 +353,9 @@ async def test_emit_tasks_passes_unknown_versioned_profile_to_queue(monkeypatch)
|
|||
assert captured["harness_profile_ref"] == "harness.unknown-locally@9.9.9"
|
||||
assert captured["approach_hint"] == "legacy-only"
|
||||
assert captured["execution_refs"] == {"correlation_id": "corr-9"}
|
||||
assert captured["repository_grant"] == {
|
||||
"version": "1",
|
||||
"allowed_paths": ["docs/", "README.md"],
|
||||
"commit_count": {"min": 1, "max": 1},
|
||||
"publish": False,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,7 +118,11 @@ def test_ops_run_to_dict_shape() -> None:
|
|||
row.source_id = "emit"
|
||||
row.triggering_event_id = "e1"
|
||||
row.approach_hint = None
|
||||
row.harness_profile_ref = None
|
||||
row.execution_refs = {}
|
||||
row.repository_grant = None
|
||||
row.result = {}
|
||||
row.close_intent_digest = None
|
||||
row.created_at = now
|
||||
row.updated_at = now
|
||||
d = ops_run_to_dict(row)
|
||||
|
|
@ -167,6 +171,33 @@ async def test_create_ops_run_inserts(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
session.execute.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_ops_run_carries_repository_grant_separately(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from activity_core.ops_run_queue import create_ops_run_from_spec
|
||||
|
||||
monkeypatch.setenv("OPS_RUN_QUEUE_ENABLED", "true")
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = uuid.uuid4()
|
||||
session = AsyncMock()
|
||||
session.execute = AsyncMock(return_value=result)
|
||||
spec = TaskSpec(
|
||||
title="bounded",
|
||||
target_repo="activity-core",
|
||||
activity_definition_id=str(uuid.uuid4()),
|
||||
source_id="emit-bounded",
|
||||
triggering_event_id="manual-1",
|
||||
)
|
||||
grant = _repository_grant()
|
||||
|
||||
await create_ops_run_from_spec(session, spec, repository_grant=grant)
|
||||
|
||||
statement = session.execute.await_args.args[0]
|
||||
assert statement.compile().params["repository_grant"] == grant
|
||||
assert statement.compile().params["execution_refs"] == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_and_complete_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""In-memory style: claim filters labels and complete transitions state."""
|
||||
|
|
@ -181,6 +212,8 @@ async def test_claim_and_complete_roundtrip(monkeypatch: pytest.MonkeyPatch) ->
|
|||
open_run.claim_owner = None
|
||||
open_run.lease_until = None
|
||||
open_run.result = {}
|
||||
open_run.repository_grant = None
|
||||
open_run.close_intent_digest = None
|
||||
|
||||
session = AsyncMock()
|
||||
|
||||
|
|
@ -234,6 +267,8 @@ async def test_complete_persists_only_normalized_glas_evidence() -> None:
|
|||
row.claim_owner = "worker-1"
|
||||
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
|
||||
row.result = {}
|
||||
row.repository_grant = None
|
||||
row.close_intent_digest = None
|
||||
session = AsyncMock()
|
||||
session.get = AsyncMock(return_value=row)
|
||||
|
||||
|
|
@ -279,6 +314,8 @@ async def test_fail_reopen_under_max_attempts(monkeypatch: pytest.MonkeyPatch) -
|
|||
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
|
||||
row.attempt = 1
|
||||
row.result = {}
|
||||
row.repository_grant = None
|
||||
row.close_intent_digest = None
|
||||
session = AsyncMock()
|
||||
session.get = AsyncMock(return_value=row)
|
||||
|
||||
|
|
@ -302,6 +339,8 @@ async def test_fail_permanent_at_max_attempts(monkeypatch: pytest.MonkeyPatch) -
|
|||
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
|
||||
row.attempt = 3
|
||||
row.result = {}
|
||||
row.repository_grant = None
|
||||
row.close_intent_digest = None
|
||||
session = AsyncMock()
|
||||
session.get = AsyncMock(return_value=row)
|
||||
|
||||
|
|
@ -322,6 +361,8 @@ async def test_fail_persists_redacted_failure_evidence() -> None:
|
|||
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
|
||||
row.attempt = 1
|
||||
row.result = {}
|
||||
row.repository_grant = None
|
||||
row.close_intent_digest = None
|
||||
session = AsyncMock()
|
||||
session.get = AsyncMock(return_value=row)
|
||||
|
||||
|
|
@ -390,6 +431,8 @@ async def test_complete_rejects_expired_lease_without_mutation() -> None:
|
|||
row.claim_owner = "worker-1"
|
||||
row.lease_until = now - timedelta(microseconds=1)
|
||||
row.result = {"before": True}
|
||||
row.repository_grant = None
|
||||
row.close_intent_digest = None
|
||||
session = AsyncMock()
|
||||
session.get = AsyncMock(return_value=row)
|
||||
|
||||
|
|
@ -416,6 +459,8 @@ async def test_fail_rejects_wrong_owner_with_active_lease() -> None:
|
|||
row.claim_owner = "worker-1"
|
||||
row.lease_until = now + timedelta(minutes=1)
|
||||
row.result = {"before": True}
|
||||
row.repository_grant = None
|
||||
row.close_intent_digest = None
|
||||
session = AsyncMock()
|
||||
session.get = AsyncMock(return_value=row)
|
||||
|
||||
|
|
@ -432,6 +477,155 @@ async def test_fail_rejects_wrong_owner_with_active_lease() -> None:
|
|||
assert row.result == {"before": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_complete_exact_repeat_reconciles_and_conflict_is_distinct() -> None:
|
||||
from activity_core import ops_run_queue as oq
|
||||
|
||||
row = MagicMock()
|
||||
row.state = "claimed"
|
||||
row.claim_owner = "worker-1"
|
||||
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
|
||||
row.result = {}
|
||||
row.repository_grant = None
|
||||
row.close_intent_digest = None
|
||||
session = AsyncMock()
|
||||
session.get = AsyncMock(return_value=row)
|
||||
|
||||
applied = await oq.close_ops_run(
|
||||
session,
|
||||
uuid.uuid4(),
|
||||
worker_id="worker-1",
|
||||
action="complete",
|
||||
result={"ok": True, "path": "docs/result.md"},
|
||||
)
|
||||
digest = row.close_intent_digest
|
||||
reconciled = await oq.close_ops_run(
|
||||
session,
|
||||
uuid.uuid4(),
|
||||
worker_id="worker-1",
|
||||
action="complete",
|
||||
result={"path": "docs/result.md", "ok": True},
|
||||
)
|
||||
conflict = await oq.close_ops_run(
|
||||
session,
|
||||
uuid.uuid4(),
|
||||
worker_id="worker-1",
|
||||
action="complete",
|
||||
result={"ok": True, "path": "docs/different.md"},
|
||||
)
|
||||
wrong_owner = await oq.close_ops_run(
|
||||
session,
|
||||
uuid.uuid4(),
|
||||
worker_id="worker-2",
|
||||
action="complete",
|
||||
result={"ok": True, "path": "docs/result.md"},
|
||||
)
|
||||
|
||||
assert applied.disposition == "applied"
|
||||
assert reconciled.disposition == "reconciled"
|
||||
assert conflict.disposition == "terminal_conflict"
|
||||
assert wrong_owner.disposition == "wrong_owner"
|
||||
assert row.state == "succeeded"
|
||||
assert row.lease_until is None
|
||||
assert row.close_intent_digest == digest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_reports_expired_lease_without_mutation() -> None:
|
||||
from activity_core import ops_run_queue as oq
|
||||
|
||||
now = datetime(2026, 9, 4, 10, 0, tzinfo=timezone.utc)
|
||||
row = MagicMock()
|
||||
row.state = "claimed"
|
||||
row.claim_owner = "worker-1"
|
||||
row.lease_until = now
|
||||
row.result = {"before": True}
|
||||
row.repository_grant = None
|
||||
row.close_intent_digest = None
|
||||
session = AsyncMock()
|
||||
session.get = AsyncMock(return_value=row)
|
||||
|
||||
with patch.object(oq, "_utcnow", return_value=now):
|
||||
outcome = await oq.close_ops_run(
|
||||
session,
|
||||
uuid.uuid4(),
|
||||
worker_id="worker-1",
|
||||
action="complete",
|
||||
result={"ok": True},
|
||||
)
|
||||
|
||||
assert outcome.disposition == "expired_lease"
|
||||
assert row.result == {"before": True}
|
||||
assert row.close_intent_digest is None
|
||||
|
||||
|
||||
def _repository_grant() -> dict:
|
||||
return {
|
||||
"version": "1",
|
||||
"allowed_paths": ["docs/", "README.md"],
|
||||
"commit_count": {"min": 1, "max": 1},
|
||||
"publish": False,
|
||||
}
|
||||
|
||||
|
||||
def _accepted_repository_result() -> dict:
|
||||
from activity_core.repository_grant import RepositoryGrant
|
||||
|
||||
grant = RepositoryGrant.model_validate(_repository_grant())
|
||||
return {
|
||||
"ok": True,
|
||||
"repository_transaction": {
|
||||
"transaction_id": "tx-1",
|
||||
"repository_grant": {
|
||||
"grant_id": grant.grant_id,
|
||||
"acceptance_policy_id": grant.acceptance_policy_id,
|
||||
"version": "1",
|
||||
},
|
||||
"acceptance": {
|
||||
"accepted": True,
|
||||
"policy_id": grant.acceptance_policy_id,
|
||||
"head": "deadbeef",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_granted_complete_requires_matching_acceptance_identity() -> None:
|
||||
from activity_core import ops_run_queue as oq
|
||||
|
||||
row = MagicMock()
|
||||
row.state = "claimed"
|
||||
row.claim_owner = "worker-1"
|
||||
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
|
||||
row.result = {}
|
||||
row.repository_grant = _repository_grant()
|
||||
row.close_intent_digest = None
|
||||
session = AsyncMock()
|
||||
session.get = AsyncMock(return_value=row)
|
||||
|
||||
mismatch = _accepted_repository_result()
|
||||
mismatch["repository_transaction"]["repository_grant"]["grant_id"] = "other"
|
||||
refused = await oq.close_ops_run(
|
||||
session,
|
||||
uuid.uuid4(),
|
||||
worker_id="worker-1",
|
||||
action="complete",
|
||||
result=mismatch,
|
||||
)
|
||||
applied = await oq.close_ops_run(
|
||||
session,
|
||||
uuid.uuid4(),
|
||||
worker_id="worker-1",
|
||||
action="complete",
|
||||
result=_accepted_repository_result(),
|
||||
)
|
||||
|
||||
assert refused.disposition == "evidence_conflict"
|
||||
assert applied.disposition == "applied"
|
||||
assert row.result["repository_transaction"]["transaction_id"] == "tx-1"
|
||||
|
||||
|
||||
def test_label_filter_any_vs_all() -> None:
|
||||
"""Document labels_mode semantics used by claim_ops_runs."""
|
||||
row_labels = {"automated", "research-brief"}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import pytest
|
|||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from activity_core.ops_run_queue import CloseOpsRunOutcome
|
||||
from activity_core.ops_runs_api import (
|
||||
_close_response,
|
||||
bind_worker_id,
|
||||
require_worker,
|
||||
require_worker_or_operator,
|
||||
|
|
@ -140,3 +142,26 @@ def test_read_auth_no_longer_defaults_open(monkeypatch: pytest.MonkeyPatch) -> N
|
|||
require_worker_or_operator(_request())
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
|
||||
|
||||
def test_close_response_distinguishes_reconciled_and_refusal_codes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
row = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"activity_core.ops_runs_api.ops_run_to_dict",
|
||||
lambda value: {"id": "run-1", "state": value.state},
|
||||
)
|
||||
row.state = "succeeded"
|
||||
|
||||
response = _close_response(CloseOpsRunOutcome("reconciled", row))
|
||||
assert response == {
|
||||
"id": "run-1",
|
||||
"state": "succeeded",
|
||||
"close_disposition": "reconciled",
|
||||
}
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_close_response(CloseOpsRunOutcome("expired_lease", row))
|
||||
assert exc.value.status_code == 409
|
||||
assert exc.value.detail["code"] == "expired_lease"
|
||||
|
|
|
|||
82
tests/test_repository_grant.py
Normal file
82
tests/test_repository_grant.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Repository-grant v1 validation and identity compatibility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from activity_core.models import ActionDef, InstructionDef
|
||||
from activity_core.orm import OpsRun
|
||||
from activity_core.repository_grant import (
|
||||
RepositoryGrant,
|
||||
RepositoryGrantError,
|
||||
validate_repository_grant,
|
||||
)
|
||||
|
||||
|
||||
def _grant(**changes: object) -> dict:
|
||||
value = {
|
||||
"version": "1",
|
||||
"allowed_paths": ["docs/", "README.md"],
|
||||
"commit_count": {"min": 1, "max": 1},
|
||||
"publish": False,
|
||||
}
|
||||
value.update(changes)
|
||||
return value
|
||||
|
||||
|
||||
def test_repository_grant_preserves_payload_and_has_stable_identity() -> None:
|
||||
raw = _grant()
|
||||
grant = RepositoryGrant.model_validate(raw)
|
||||
reordered = RepositoryGrant.model_validate(
|
||||
_grant(allowed_paths=["README.md", "docs/"])
|
||||
)
|
||||
|
||||
assert grant.payload() == raw
|
||||
assert grant.payload() is not raw
|
||||
assert grant.grant_id == reordered.grant_id
|
||||
assert grant.acceptance_policy_id == reordered.acceptance_policy_id
|
||||
assert grant.grant_id == "af2e7c8275c9ba8c8f78485067f9608e"
|
||||
assert grant.acceptance_policy_id == "2ed31282d721f4466bc9ea722067d9d0"
|
||||
|
||||
|
||||
def test_action_and_instruction_parse_repository_grant_as_typed_authority() -> None:
|
||||
action = ActionDef(task_template="Do work", repository_grant=_grant())
|
||||
instruction = InstructionDef(
|
||||
id="do-work",
|
||||
trusted_fields=[],
|
||||
model="model",
|
||||
prompt="Do work",
|
||||
output_schema="schema.json",
|
||||
repository_grant=_grant(),
|
||||
)
|
||||
|
||||
assert isinstance(action.repository_grant, RepositoryGrant)
|
||||
assert isinstance(instruction.repository_grant, RepositoryGrant)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
_grant(extra="not-allowed"),
|
||||
_grant(version=1),
|
||||
_grant(allowed_paths=[]),
|
||||
_grant(allowed_paths=["docs/", "docs/"]),
|
||||
_grant(allowed_paths=["../escape"]),
|
||||
_grant(allowed_paths=[".git/config"]),
|
||||
_grant(commit_count={"min": True, "max": 1}),
|
||||
_grant(commit_count={"min": 2, "max": 1}),
|
||||
_grant(commit_count={"min": 1, "max": 33}),
|
||||
_grant(publish=True),
|
||||
_grant(publish=0),
|
||||
],
|
||||
)
|
||||
def test_repository_grant_rejects_ambiguous_or_unsafe_values(value: dict) -> None:
|
||||
with pytest.raises(RepositoryGrantError):
|
||||
validate_repository_grant(value)
|
||||
|
||||
|
||||
def test_ops_run_schema_has_separate_grant_and_close_identity_columns() -> None:
|
||||
columns = OpsRun.__table__.columns
|
||||
|
||||
assert columns["repository_grant"].nullable is True
|
||||
assert columns["close_intent_digest"].nullable is True
|
||||
|
|
@ -117,6 +117,8 @@ def test_ops_summary_exposes_compact_execution_constellation() -> None:
|
|||
row.approach_hint = "legacy-only"
|
||||
row.harness_profile_ref = "harness.agent-dev@1.0.0"
|
||||
row.execution_refs = {"goal_refs": ["goal:42@1"]}
|
||||
row.repository_grant = None
|
||||
row.close_intent_digest = "close-digest"
|
||||
row.created_at = now
|
||||
row.updated_at = now
|
||||
row.result = {
|
||||
|
|
|
|||
|
|
@ -145,6 +145,42 @@ def test_definition_parse_rejects_a_malformed_profile(tmp_path) -> None:
|
|||
parse_file(tmp_path / "profiled.md")
|
||||
|
||||
|
||||
def test_definition_parse_validates_and_preserves_repository_grant(tmp_path) -> None:
|
||||
_write_profiled_definition(
|
||||
tmp_path,
|
||||
""" repository_grant:
|
||||
version: "1"
|
||||
allowed_paths: [docs/, README.md]
|
||||
commit_count: {min: 1, max: 1}
|
||||
publish: false
|
||||
""",
|
||||
)
|
||||
|
||||
definition = parse_file(tmp_path / "profiled.md")
|
||||
|
||||
assert definition.rules[0]["action"]["repository_grant"] == {
|
||||
"version": "1",
|
||||
"allowed_paths": ["docs/", "README.md"],
|
||||
"commit_count": {"min": 1, "max": 1},
|
||||
"publish": False,
|
||||
}
|
||||
|
||||
|
||||
def test_definition_parse_rejects_unsafe_repository_grant(tmp_path) -> None:
|
||||
_write_profiled_definition(
|
||||
tmp_path,
|
||||
""" repository_grant:
|
||||
version: "1"
|
||||
allowed_paths: [../escape]
|
||||
commit_count: {min: 1, max: 1}
|
||||
publish: false
|
||||
""",
|
||||
)
|
||||
|
||||
with pytest.raises(ParseError, match="invalid repository_grant"):
|
||||
parse_file(tmp_path / "profiled.md")
|
||||
|
||||
|
||||
def test_definition_parse_requires_rule_profile_in_strict_mode(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue