mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-16 14:32:34 +00:00
unbroker finds where a consenting person's info is exposed across data brokers and people-search sites and files the removals, running as far as each site allows and handing only genuinely human-only steps (hard CAPTCHA, gov-ID, phone, fax) back as an end-of-run digest. - Deterministic stdlib CLI (scripts/pdd.py) owns config, dossiers+consent, the broker DB, tier planning, the ledger, email, and the autonomous action queue; the agent scans/submits with native tools (web_extract, browser_*, delegate_task, cronjob, terminal). - Verify-before-disclose, least-disclosure (never volunteers SSN), consent gate, opaque ids, optional age-at-rest encryption, file-locked ledger. - Jurisdiction-aware (CCPA/CPRA, GDPR, generic); CA DROP one-shot covers the state registry (~545) in a single request; BADBOOL + curated people-search coverage; scheduled re-scan for re-listing. - No CAPTCHA-solving services or anti-bot bypass; browser email mode needs no stored password. - 85 hermetic tests (tests/skills/test_unbroker_skill.py; SMTP/IMAP via injected fakes, registry via CSV fixtures). Ships placeholder data only. Broker dataset adapted from BADBOOL (Yael Grauer, CC BY-NC-SA 4.0).
135 lines
4.8 KiB
Python
135 lines
4.8 KiB
Python
"""Subject dossier management + consent gate + least-disclosure field selection."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as _dt
|
|
import hashlib
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import paths
|
|
import storage
|
|
|
|
# Identifiers we never volunteer in an opt-out (would expand exposure, not reduce it).
|
|
NEVER_VOLUNTEER = {"ssn", "social_security_number", "passport", "drivers_license"}
|
|
|
|
VALID_CONSENT_METHODS = {"self", "written_authorization", "poa"}
|
|
|
|
|
|
def now() -> str:
|
|
return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def new_subject_id(full_name: str = "") -> str:
|
|
# Opaque id: derives NOTHING from the name, so PII never leaks into directory names,
|
|
# case ids, drafts, or the audit log. full_name kept only for call compatibility.
|
|
return "sub_" + hashlib.sha1(os.urandom(8)).hexdigest()[:10]
|
|
|
|
|
|
def create(identity: dict, consent: dict, residency: str = "US", prefs: dict | None = None) -> dict:
|
|
dossier = {
|
|
"subject_id": new_subject_id(identity.get("full_name", "subject")),
|
|
"consent": consent,
|
|
"identity": identity,
|
|
"residency_jurisdiction": residency,
|
|
"preferences": prefs or {"email_mode": "draft_only", "rescan_interval_days": 120},
|
|
"created_at": now(),
|
|
}
|
|
save(dossier)
|
|
return dossier
|
|
|
|
|
|
def load(subject_id: str) -> dict | None:
|
|
return storage.read_json(paths.dossier_path(subject_id), None)
|
|
|
|
|
|
def save(dossier: dict) -> Path:
|
|
return storage.write_json(paths.dossier_path(dossier["subject_id"]), dossier)
|
|
|
|
|
|
def is_authorized(dossier: dict) -> bool:
|
|
c = dossier.get("consent") or {}
|
|
return bool(c.get("authorized")) and c.get("method") in VALID_CONSENT_METHODS
|
|
|
|
|
|
def require_authorized(dossier: dict) -> None:
|
|
if not is_authorized(dossier):
|
|
raise PermissionError(
|
|
f"subject {dossier.get('subject_id')!r} has no recorded authorization; refusing to act"
|
|
)
|
|
|
|
|
|
def all_names(dossier: dict) -> list[str]:
|
|
"""Primary name + aliases (maiden/married/nicknames), deduped, in priority order."""
|
|
ident = dossier.get("identity", {})
|
|
out: list[str] = []
|
|
seen: set[str] = set()
|
|
for n in [ident.get("full_name"), *(ident.get("also_known_as") or [])]:
|
|
if n and n.lower() not in seen:
|
|
seen.add(n.lower())
|
|
out.append(n)
|
|
return out
|
|
|
|
|
|
def all_addresses(dossier: dict) -> list[dict]:
|
|
"""Current + prior addresses, each tagged with `kind` (current|prior)."""
|
|
ident = dossier.get("identity", {})
|
|
out: list[dict] = []
|
|
cur = ident.get("current_address")
|
|
if cur:
|
|
out.append({**cur, "kind": cur.get("kind", "current")})
|
|
for a in ident.get("prior_addresses") or []:
|
|
out.append({**a, "kind": a.get("kind", "prior")})
|
|
return out
|
|
|
|
|
|
def all_locations(dossier: dict) -> list[dict]:
|
|
"""Distinct city/state pairs across all addresses (the vectors for name searches)."""
|
|
out: list[dict] = []
|
|
seen: set[tuple] = set()
|
|
for a in all_addresses(dossier):
|
|
city = a.get("city")
|
|
key = ((city or "").lower(), (a.get("state") or "").lower())
|
|
if city and key not in seen:
|
|
seen.add(key)
|
|
out.append({"city": city, "state": a.get("state")})
|
|
return out
|
|
|
|
|
|
def contact_email(dossier: dict) -> str | None:
|
|
"""The single email used for opt-out correspondence (designated, else the first)."""
|
|
ident = dossier.get("identity", {})
|
|
prefs = dossier.get("preferences", {})
|
|
emails = ident.get("emails") or []
|
|
return prefs.get("contact_email_for_optouts") or (emails[0] if emails else None)
|
|
|
|
|
|
def select_disclosure(dossier: dict, inputs: list[str], override_email: str | None = None) -> dict:
|
|
"""Return ONLY the dossier fields a broker's opt-out actually requires.
|
|
|
|
Enforces least-disclosure: skips anything in NEVER_VOLUNTEER, and skips
|
|
`profile_url` (that is captured per-listing at submit time, not from the dossier).
|
|
A single contact email is used for correspondence even when the subject has several
|
|
(see all_names / all_addresses / search vectors for using every alternate to *find* listings).
|
|
"""
|
|
ident = dossier.get("identity", {})
|
|
addr = ident.get("current_address") or {}
|
|
phones = ident.get("phones") or []
|
|
available = {
|
|
"full_name": ident.get("full_name"),
|
|
"first_name": (ident.get("full_name") or "").split(" ")[0] or None,
|
|
"contact_email": override_email or contact_email(dossier),
|
|
"current_address": addr or None,
|
|
"street": addr.get("line1"),
|
|
"city": addr.get("city"),
|
|
"state": addr.get("state"),
|
|
"postal": addr.get("postal"),
|
|
"date_of_birth": ident.get("date_of_birth"),
|
|
"phone": phones[0] if phones else None,
|
|
}
|
|
out: dict = {}
|
|
for key in inputs:
|
|
if key in NEVER_VOLUNTEER or key == "profile_url":
|
|
continue
|
|
if available.get(key) is not None:
|
|
out[key] = available[key]
|
|
return out
|