fix(gate): complete owning-PR renewal enforcement coverage (#945)
Addresses review 623 on PR #946 (B1 blocker, F2 medium, F3 minor).
B1 - the wiring this branch exists to install had no regression coverage.
The existing suite exercised owning_pr_renewal_from_lock,
_owning_pr_continuation_from_lock and the duplicate gate directly, but never
drove an enforcement path, so reverting any of the three call sites left the
whole repository green. Add tests/test_issue_945_enforcement_path_wiring.py,
which drives the real mcp_server._enforce_locked_issue_duplicate_recheck (the
shared recheck behind gitea_commit_files and gitea_create_pr),
mcp_server.gitea_assess_work_issue_duplicate and
mcp_server._prove_author_ownership_for_pr against a renewal-bearing lock, and
asserts each grants the exemption. Reverting the commit/create-PR recheck to
the recovery-only rebuild now fails 8 tests and 4 subtests; reverting the
assessor or the push prover fails 2 each. The suite also keeps the fail-closed
matrix on the real paths: an open PR alone, a second PR, a different PR,
branch, issue or head, identity and profile mismatch, ungranted and malformed
renewal blocks, and sequential-task non-inheritance are all still refused.
F2 - the claimant check compares lease_renewal.identity/profile against the
claimant recorded on the same lock file. Both sides are server-written fields
of one document, so it is an internal-consistency check, not verification of
the live authenticated caller. Correct the docstring and the inline comment to
say so, and document the binding that actually prevents cross-session reuse:
the enforcement paths load the lock through _load_existing_issue_lock() with no
issue coordinates, which resolves issue_lock_store.read_session_issue_lock() to
the session pointer at session-{os.getpid()}.json, so lock selection is scoped
to the operating-system process. Its limits are stated too - per-process rather
than per-authenticated-user, silent on locks reached by explicit coordinates,
and silent on two roles sharing one process. Live identity and profile stay
enforced by the mutation-authority and profile gates, not by this rebuild.
F3 - _owning_pr_continuation_from_lock previously fell through to renewal when
a dead_session_recovery block was present but failed to rebuild, so a recovery
record naming one PR could be bypassed by renewal evidence naming another.
Present-but-unusable recovery evidence is now ambiguous rather than absent and
fails closed. Because an expired lease whose recorded owner has also died
satisfies both dispositions in one gitea_lock_issue call, a sanctioned pair is
a reachable state; when both rebuild, they must agree on issue, PR, branch and
every head, or no continuation authority is returned. Recovery-only and
renewal-only locks keep their existing behaviour exactly.
Validation of the resulting token against live PR state remains untouched and
solely owned by issue_work_duplicate_gate._assess_owning_pr_exemption. No
public signature, MCP tool schema, refusal shape or reason code changes.
Tests: focused #945/#755/#760 suites 137 passed, 19 subtests. Full suite in a
branches/ worktree: 30F/5619P/6S/1013 subtests at this head vs 30F/5572P/6S/1002
at a clean checkout of 79334d48, with byte-identical failing test id sets - the
+47 passes and +11 subtests are exactly the new coverage.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q8RUznLXEA4JoK48sTZiSK
This commit is contained in:
+52
-1
@@ -2781,6 +2781,29 @@ def _collect_issue_duplicate_context(
|
||||
return issue_duplicate_context_fetcher(h, o, r, auth, issue_number)
|
||||
|
||||
|
||||
# Every field of the owning-PR continuation token is security relevant: the
|
||||
# issue and PR it names, the branch it is scoped to, and each head the waiver
|
||||
# was measured against. Two evidence blocks that disagree on any of them cannot
|
||||
# both describe the single sanctioned decision the lock is supposed to record.
|
||||
_CONTINUATION_EVIDENCE_BINDINGS = (
|
||||
"issue_number",
|
||||
"pr_number",
|
||||
"branch_name",
|
||||
"head_sha",
|
||||
"recorded_head",
|
||||
"accepted_head",
|
||||
"head_relation",
|
||||
)
|
||||
|
||||
|
||||
def _continuation_evidence_agrees(recovered: dict, renewed: dict) -> bool:
|
||||
"""Do two rebuilt continuation tokens bind to exactly the same thing (#945)?"""
|
||||
return all(
|
||||
recovered.get(field) == renewed.get(field)
|
||||
for field in _CONTINUATION_EVIDENCE_BINDINGS
|
||||
)
|
||||
|
||||
|
||||
def _owning_pr_continuation_from_lock(lock_record: dict | None) -> dict | None:
|
||||
"""Owning-PR continuation evidence a persisted lock still proves (#945).
|
||||
|
||||
@@ -2802,13 +2825,41 @@ def _owning_pr_continuation_from_lock(lock_record: dict | None) -> dict | None:
|
||||
token is rebuilt from — the token is still re-validated against live PR
|
||||
state by ``issue_work_duplicate_gate._assess_owning_pr_exemption``, which
|
||||
remains the single authoritative policy for whether an exemption applies.
|
||||
|
||||
**Both blocks present is a reachable, legitimate state, and it must agree.**
|
||||
The two dispositions are not mutually exclusive at the writer. Recovery is
|
||||
assessed whenever the lease is not live and requires the recorded PID to be
|
||||
dead; renewal is assessed whenever the lease has *expired* — which is itself
|
||||
one way to be non-live — and deliberately does not branch on PID liveness
|
||||
(#760 AC16). An expired lease whose recorded owner has also died therefore
|
||||
satisfies both, and ``gitea_lock_issue`` writes ``dead_session_recovery``
|
||||
and ``lease_renewal`` into the same freshly built ``data`` dict. Because a
|
||||
sanctioned pair was derived from one live observation in one call, it always
|
||||
describes the same issue, PR, branch and head. Disagreement means the
|
||||
persisted lock is no longer a faithful record of a single sanctioned
|
||||
decision, so no continuation authority is returned.
|
||||
|
||||
Ambiguity never broadens authority. A ``dead_session_recovery`` block that
|
||||
is present but does not rebuild — conflicting, stale, malformed, or only
|
||||
partially valid — fails closed here rather than falling through to renewal:
|
||||
otherwise a recovery record naming one PR could be bypassed by valid-looking
|
||||
renewal evidence naming another. Recovery-only and renewal-only locks keep
|
||||
their existing behaviour exactly, and provenance stays server-controlled —
|
||||
this still only ever re-reads blocks the server itself wrote.
|
||||
"""
|
||||
if not lock_record:
|
||||
return None
|
||||
recovery_present = isinstance(lock_record.get("dead_session_recovery"), dict)
|
||||
recovered = issue_lock_recovery.recovered_owning_pr_from_lock(lock_record)
|
||||
# Present but unusable recovery evidence is ambiguous, not absent.
|
||||
if recovery_present and not recovered:
|
||||
return None
|
||||
renewed = issue_lock_renewal.owning_pr_renewal_from_lock(lock_record)
|
||||
if recovered and renewed and not _continuation_evidence_agrees(recovered, renewed):
|
||||
return None
|
||||
if recovered:
|
||||
return recovered
|
||||
return issue_lock_renewal.owning_pr_renewal_from_lock(lock_record)
|
||||
return renewed
|
||||
|
||||
|
||||
def _assess_issue_duplicate_gate(
|
||||
|
||||
Reference in New Issue
Block a user