feat(reports): add no-email identity summary and disclosure validator (closes #305)

Workflow reports included the authenticated user's personal email even
when username/profile identity was sufficient (observed: 'Identity:
jcwalker3 / [email protected]' in a reconciliation handoff).

Add format_identity_summary() producing the canonical no-email identity
line ('<username> / <profile>' with optional role/remote, reducing an
email passed in the username slot to its local part), and
assess_email_disclosure() flagging personal email addresses in report
text unless the report itself justifies the disclosure (tool proof,
explicit request, or identity disambiguation).

Tests cover no-email summaries for author and reviewer identities,
role/remote suffixes, email-in-username reduction, clean reports,
single and multiple unjustified disclosures, and justified
disambiguation.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-07 02:57:45 -04:00
co-authored by Claude Opus 4.8
parent a5341684f6
commit a3b79736e0
2 changed files with 183 additions and 0 deletions
+81
View File
@@ -2471,3 +2471,84 @@ def build_review_mutation_proof(run_log: list[dict]) -> dict:
"missing_fields": [],
"reasons": [],
}
# ---------------------------------------------------------------------------
# Identity disclosure (#305)
# ---------------------------------------------------------------------------
EMAIL_ADDRESS_RE = re.compile(
r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
EMAIL_JUSTIFICATION_MARKERS = (
"email required",
"email is required",
"email necessary",
"necessary to disambiguate",
"disambiguate identity",
"tool requires the email",
"user explicitly asked",
)
def format_identity_summary(username, profile, role=None, remote=None):
"""Return the no-email identity line for workflow reports (#305).
Standard reports identify actors as ``<username> / <profile>`` (plus
optional role/remote). Personal email is never part of the summary; if
an email lands in the username slot, only its local part is kept.
"""
name = (username or "").strip()
if "@" in name:
name = name.split("@", 1)[0]
summary = f"{name} / {(profile or '').strip()}"
extras = [str(part).strip() for part in (role, remote)
if part and str(part).strip()]
if extras:
summary += f" ({', '.join(extras)})"
return summary
def assess_email_disclosure(
report_text,
*,
justification_markers=EMAIL_JUSTIFICATION_MARKERS,
):
"""Flag unnecessary personal-email disclosure in a report (#305).
Username/profile identity is sufficient for normal workflow reports.
An email address is tolerated only when the report itself explains why
it is necessary (tool proof, explicit request, or disambiguation).
"""
text = report_text or ""
emails = sorted(set(EMAIL_ADDRESS_RE.findall(text)))
if not emails:
return {
"proven": True,
"flagged": False,
"justified": False,
"emails": [],
"reasons": [],
}
lower = text.lower()
justified = any(marker in lower for marker in justification_markers)
if justified:
return {
"proven": True,
"flagged": False,
"justified": True,
"emails": emails,
"reasons": [
"email disclosure present but justified in the report",
],
}
return {
"proven": False,
"flagged": True,
"justified": False,
"emails": emails,
"reasons": [
f"unnecessary personal email disclosure: {email}"
for email in emails
],
}