diff --git a/review_proofs.py b/review_proofs.py index 72be5fb..9a37ce5 100644 --- a/review_proofs.py +++ b/review_proofs.py @@ -2610,3 +2610,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 `` / `` (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 + ], + } diff --git a/tests/test_identity_disclosure.py b/tests/test_identity_disclosure.py new file mode 100644 index 0000000..6b12e3d --- /dev/null +++ b/tests/test_identity_disclosure.py @@ -0,0 +1,102 @@ +"""Identity-disclosure checks for workflow reports (#305). + +Workflow reports sometimes included the authenticated user's personal +email even though username/profile identity is sufficient (observed: +``Identity: jcwalker3 / jcwalker3@yahoo.com`` in a reconciliation +handoff). These tests pin the no-email identity summary helper and the +final-report validator that flags unnecessary email disclosure. +""" +import sys +import unittest + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import review_proofs + + +class TestIdentitySummary(unittest.TestCase): + def test_author_summary_uses_username_and_profile_only(self): + summary = review_proofs.format_identity_summary( + "jcwalker3", "prgs-author") + self.assertEqual(summary, "jcwalker3 / prgs-author") + self.assertNotIn("@", summary) + + def test_reviewer_summary_uses_username_and_profile_only(self): + summary = review_proofs.format_identity_summary( + "sysadmin", "prgs-reviewer") + self.assertEqual(summary, "sysadmin / prgs-reviewer") + + def test_summary_appends_role_and_remote_without_email(self): + summary = review_proofs.format_identity_summary( + "jcwalker3", "prgs-author", role="author", remote="prgs") + self.assertIn("jcwalker3 / prgs-author", summary) + self.assertIn("author", summary) + self.assertIn("prgs", summary) + self.assertNotIn("@", summary) + + def test_summary_never_leaks_email_passed_as_username(self): + """Defense in depth: an email in the username slot is reduced.""" + summary = review_proofs.format_identity_summary( + "jcwalker3@yahoo.com", "prgs-author") + self.assertNotIn("@", summary) + self.assertIn("jcwalker3", summary) + + +class TestEmailDisclosureAssessment(unittest.TestCase): + def test_report_without_email_passes(self): + report = "\n".join([ + "Controller Handoff", + "Identity: jcwalker3 / prgs-author", + "Role: author", + ]) + res = review_proofs.assess_email_disclosure(report) + self.assertTrue(res["proven"]) + self.assertFalse(res["flagged"]) + self.assertEqual(res["emails"], []) + self.assertEqual(res["reasons"], []) + + def test_unnecessary_email_is_flagged(self): + report = "\n".join([ + "Controller Handoff", + "Identity: jcwalker3 / jcwalker3@yahoo.com", + ]) + res = review_proofs.assess_email_disclosure(report) + self.assertFalse(res["proven"]) + self.assertTrue(res["flagged"]) + self.assertIn("jcwalker3@yahoo.com", res["emails"]) + self.assertTrue(res["reasons"]) + self.assertFalse(res["justified"]) + + def test_multiple_emails_all_reported(self): + report = ( + "Identity: jcwalker3@yahoo.com author\n" + "Reviewer: 913443@dadeschools.net\n" + ) + res = review_proofs.assess_email_disclosure(report) + self.assertTrue(res["flagged"]) + self.assertEqual( + sorted(res["emails"]), + ["913443@dadeschools.net", "jcwalker3@yahoo.com"], + ) + + def test_justified_email_with_explanation_is_not_flagged(self): + report = "\n".join([ + "Identity: jcwalker3 / prgs-author", + "Contact email: jcwalker3@yahoo.com", + "Email required because two accounts share the username and the", + "address is necessary to disambiguate identity.", + ]) + res = review_proofs.assess_email_disclosure(report) + self.assertFalse(res["flagged"]) + self.assertTrue(res["justified"]) + self.assertIn("jcwalker3@yahoo.com", res["emails"]) + + def test_email_without_justification_language_is_flagged(self): + report = "Contact email: jcwalker3@yahoo.com just in case.\n" + res = review_proofs.assess_email_disclosure(report) + self.assertTrue(res["flagged"]) + self.assertFalse(res["justified"]) + + +if __name__ == "__main__": + unittest.main()