Merge master into feat/issue-204
This commit is contained in:
+195
-1
@@ -330,9 +330,97 @@ def assess_self_review_contamination(reviewer_identity, pr_author,
|
||||
}
|
||||
|
||||
|
||||
def assess_role_boundary(proof):
|
||||
"""Assess reviewer/author role separation for blind queue workflows.
|
||||
|
||||
Issue #175 blocks a reviewer queue task from silently becoming author
|
||||
implementation work. The workflow may use both namespaces only when that
|
||||
mixed use is explicit, justified, and non-mutating; author mutations after
|
||||
a reviewer queue task require an explicit operator authorization.
|
||||
|
||||
*proof* keys:
|
||||
``task_role`` ('reviewer' or 'author'), ``task_kind`` (for example
|
||||
'blind_pr_queue_review'), ``reviewer_namespace_used``,
|
||||
``author_namespace_used``, ``author_mutations`` (list), ``review_mutations``
|
||||
(list), ``operator_authorized_author_work``, ``mixed_namespace_justification``,
|
||||
``scratch_evidence_claimed``, and ``scratch_evidence_durable``.
|
||||
"""
|
||||
proof = proof or {}
|
||||
task_role = (proof.get("task_role") or "").strip().lower()
|
||||
task_kind = (proof.get("task_kind") or "").strip().lower()
|
||||
author_mutations = list(proof.get("author_mutations") or [])
|
||||
review_mutations = list(proof.get("review_mutations") or [])
|
||||
reviewer_used = bool(proof.get("reviewer_namespace_used"))
|
||||
author_used = bool(proof.get("author_namespace_used"))
|
||||
authorized = bool(proof.get("operator_authorized_author_work"))
|
||||
mixed_justification = (
|
||||
proof.get("mixed_namespace_justification") or ""
|
||||
).strip()
|
||||
scratch_claimed = bool(proof.get("scratch_evidence_claimed"))
|
||||
scratch_durable = bool(proof.get("scratch_evidence_durable"))
|
||||
|
||||
reasons = []
|
||||
violations = []
|
||||
|
||||
if task_role not in {"reviewer", "author"}:
|
||||
reasons.append("task role missing or unknown; role boundary unproven")
|
||||
|
||||
if task_role == "reviewer":
|
||||
if author_mutations and not authorized:
|
||||
violations.append(
|
||||
"reviewer task performed author mutations without explicit "
|
||||
"operator authorization"
|
||||
)
|
||||
if author_used and not mixed_justification:
|
||||
reasons.append(
|
||||
"reviewer task used author namespace without an explicit "
|
||||
"justification"
|
||||
)
|
||||
if task_kind == "blind_pr_queue_review" and author_mutations:
|
||||
if not authorized:
|
||||
violations.append(
|
||||
"blind PR queue review silently pivoted into author "
|
||||
"implementation"
|
||||
)
|
||||
elif task_role == "author":
|
||||
if review_mutations:
|
||||
violations.append(
|
||||
"author task performed reviewer-only mutations"
|
||||
)
|
||||
|
||||
if reviewer_used and author_used and not mixed_justification:
|
||||
reasons.append(
|
||||
"mixed reviewer+author namespace use was not reported as a "
|
||||
"role-boundary event"
|
||||
)
|
||||
|
||||
if scratch_claimed and not scratch_durable:
|
||||
reasons.append(
|
||||
"scratch-only notes were claimed as durable evidence"
|
||||
)
|
||||
|
||||
if violations:
|
||||
status = "violation"
|
||||
safe_next_action = "stop; report role-boundary violation"
|
||||
elif reasons:
|
||||
status = "warning"
|
||||
safe_next_action = "downgrade final report; do not claim A-level proof"
|
||||
else:
|
||||
status = "clean"
|
||||
safe_next_action = "proceed"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"clean": status == "clean",
|
||||
"reasons": reasons,
|
||||
"violations": violations,
|
||||
"safe_next_action": safe_next_action,
|
||||
}
|
||||
|
||||
|
||||
def build_final_report(checkout_proof, inventory, validation, contamination,
|
||||
identity_eligible, merge_performed,
|
||||
issue_status_verified):
|
||||
issue_status_verified, role_boundary=None):
|
||||
"""Required behavior 6 + acceptance criteria: one report, distinct proofs.
|
||||
|
||||
Combines the individual proof verdicts into the final-report fields the
|
||||
@@ -348,6 +436,12 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
|
||||
checkout_proven = bool(checkout_proof.get("proven"))
|
||||
validation_claimable = bool(validation.get("claimable"))
|
||||
validation_strong = validation.get("verdict") == "strong"
|
||||
role_boundary = role_boundary or {
|
||||
"status": "warning",
|
||||
"reasons": ["role-boundary proof missing"],
|
||||
"violations": [],
|
||||
}
|
||||
role_status = role_boundary.get("status", "warning")
|
||||
|
||||
downgrade_reasons = []
|
||||
if not identity_eligible:
|
||||
@@ -367,6 +461,9 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
|
||||
downgrade_reasons.append(
|
||||
f"session contamination status is '{contamination_status}'"
|
||||
)
|
||||
if role_status != "clean":
|
||||
downgrade_reasons.append(f"role boundary status is '{role_status}'")
|
||||
downgrade_reasons.extend(role_boundary.get("reasons", []))
|
||||
if not issue_status_verified:
|
||||
downgrade_reasons.append("linked issue status not verified")
|
||||
|
||||
@@ -374,6 +471,7 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
|
||||
identity_eligible
|
||||
and checkout_proven
|
||||
and contamination_status == "clean"
|
||||
and role_status == "clean"
|
||||
and validation_claimable
|
||||
and validation.get("verdict") != "invalid"
|
||||
)
|
||||
@@ -384,6 +482,7 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
|
||||
"merge was performed/claimed although the proofs did not allow "
|
||||
"one; this run is blocked, not graded"
|
||||
)
|
||||
violations.extend(role_boundary.get("violations", []))
|
||||
|
||||
if violations:
|
||||
grade = "blocked"
|
||||
@@ -400,6 +499,7 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
|
||||
"pr_author_distinct_from_reviewer":
|
||||
contamination_status in ("clean",),
|
||||
"session_contamination": contamination_status,
|
||||
"role_boundary": role_status,
|
||||
"inventory_complete": bool(inventory.get("complete")),
|
||||
"validated_on_pinned_head": checkout_proven and validation_claimable,
|
||||
"validation_passed":
|
||||
@@ -562,3 +662,97 @@ def assess_controller_handoff(report_text, role=None):
|
||||
"missing_fields": [],
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
|
||||
# ── PR Inventory Trust Gate (Issue #194) ──────────────────────────────────────
|
||||
#
|
||||
# A reviewer agent may not convert an empty PR list response into a definitive
|
||||
# "no open PRs" conclusion unless the inventory result is independently proven
|
||||
# trustworthy.
|
||||
|
||||
def pr_inventory_trust_gate(
|
||||
list_prs_response: list | None,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
state: str | None = None,
|
||||
authenticated_profile: dict | None = None,
|
||||
local_remote_url: str | None = None,
|
||||
user_context: str | None = None,
|
||||
corroboration_open_pr_counter: int | None = None,
|
||||
has_finality_metadata: bool = False,
|
||||
) -> dict:
|
||||
"""Evaluate whether an empty PR list is trusted or untrusted.
|
||||
|
||||
Returns a dict with 'status', 'reasons', and 'corroborated'.
|
||||
"""
|
||||
if list_prs_response is None or not isinstance(list_prs_response, list):
|
||||
return {
|
||||
"status": "inventory_error",
|
||||
"reasons": ["PR list response is invalid (not a list or None)"],
|
||||
"corroborated": False,
|
||||
}
|
||||
|
||||
if len(list_prs_response) > 0:
|
||||
return {
|
||||
"status": "trusted_nonempty",
|
||||
"reasons": [],
|
||||
"corroborated": False,
|
||||
}
|
||||
|
||||
reasons = []
|
||||
|
||||
# 1. Exact remote, owner, repo, and state filter resolved correctly
|
||||
if not remote or remote not in ("dadeschools", "prgs"):
|
||||
reasons.append("remote instance is invalid or unresolved")
|
||||
if not org or not org.strip():
|
||||
reasons.append("owner/org is invalid or unresolved")
|
||||
if not repo or not repo.strip():
|
||||
reasons.append("repository name is invalid or unresolved")
|
||||
if state != "open":
|
||||
reasons.append("state filter is not 'open'")
|
||||
|
||||
# 2. Authenticated profile permission check
|
||||
if not authenticated_profile or not isinstance(authenticated_profile, dict):
|
||||
reasons.append("authenticated profile is missing or invalid")
|
||||
else:
|
||||
allowed = authenticated_profile.get("allowed_operations") or []
|
||||
if "gitea.read" not in allowed and "read" not in allowed:
|
||||
reasons.append("authenticated profile lacks read permissions")
|
||||
|
||||
# 3. Pagination/finality metadata or independent read path corroboration
|
||||
corroborated = False
|
||||
if has_finality_metadata:
|
||||
corroborated = True
|
||||
elif corroboration_open_pr_counter == 0:
|
||||
corroborated = True
|
||||
else:
|
||||
reasons.append("pagination finality not proven and open_pr_counter corroboration is missing or non-zero")
|
||||
|
||||
# 4. Local checkout remote URL matching the target repo
|
||||
if not local_remote_url or not isinstance(local_remote_url, str):
|
||||
reasons.append("local checkout remote URL is missing or invalid")
|
||||
else:
|
||||
expected = f"{org}/{repo}".lower()
|
||||
if expected not in local_remote_url.lower():
|
||||
reasons.append(f"local remote URL does not match target repository '{org}/{repo}'")
|
||||
|
||||
# 5. User context check (indicators that PRs should exist)
|
||||
if user_context and isinstance(user_context, str):
|
||||
indicators = ["pr #", "pull request #", "open pr", "pr queue"]
|
||||
found = [ind for ind in indicators if ind in user_context.lower()]
|
||||
if found:
|
||||
reasons.append(f"user context indicates open PRs should exist (matched: {', '.join(found)})")
|
||||
|
||||
if reasons:
|
||||
return {
|
||||
"status": "untrusted_empty",
|
||||
"reasons": reasons,
|
||||
"corroborated": corroborated,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "trusted_empty",
|
||||
"reasons": [],
|
||||
"corroborated": corroborated,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user