unbroker

Autonomously remove your info from data-broker sites.

  • privacy
  • data-broker
  • opt-out
  • ccpa
  • gdpr
  • security
  • doxxing

Declared platforms: linux · macos · windows

Install
npx skills add 'https://github.com/NousResearch/hermes-agent/tree/main/optional-skills/security/unbroker'
Download bundle ↓
main · 24fd22bScanned 2026-09-15

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗

scripts/ledger.py

scripts/ledger.pyBrowse 56 files
View on GitHub
← Back to SKILL.md
"""Case ledger: opt-out state machine + append-only audit log. A "case" is one (subject x broker) record. State changes are validated againstTRANSITIONS and mirrored into audit.jsonl so every action is auditable."""from __future__ import annotations import datetime as _dtfrom pathlib import Path import pathsimport storage STATES = [    "new", "searching", "not_found", "found", "indirect_exposure", "action_selected", "submitted",    "verification_pending", "awaiting_processing", "confirmed_removed", "reappeared",    "human_task_queued", "blocked",] TRANSITIONS: dict[str, set[str]] = {    "new": {"searching", "found", "not_found", "indirect_exposure", "blocked"},    "searching": {"not_found", "found", "indirect_exposure", "blocked"},    "not_found": {"searching", "found", "indirect_exposure", "blocked"},    # found -> not_found: a parent re-verification (or re-scan) found the "found" was a false    # positive (namesake, or an address-only property-record match) -- retract it with evidence.    "found": {"action_selected", "submitted", "human_task_queued", "indirect_exposure", "blocked",              "not_found"},    # indirect_exposure: subject's PII (email/phone/name) sits on a THIRD PARTY's record. The    # self-service opt-out form does not apply; the lever is a targeted CCPA/GDPR delete-my-PII    # request (-> submitted) or a human task. Re-scan can clear it (-> not_found) or upgrade it to a    # direct listing (-> found).    "indirect_exposure": {"submitted", "human_task_queued", "not_found", "found", "blocked"},    "action_selected": {"submitted", "human_task_queued", "blocked"},    "submitted": {"verification_pending", "awaiting_processing", "human_task_queued", "blocked"},    # verification_pending -> awaiting_processing: the verify link was opened/acknowledged and the    # broker is now processing the removal (their stated window). confirmed_removed still requires a    # verifying re-scan, never the submission flow's own say-so.    "verification_pending": {"awaiting_processing", "confirmed_removed", "human_task_queued", "blocked"},    "awaiting_processing": {"confirmed_removed", "human_task_queued", "blocked"},    "confirmed_removed": {"reappeared", "confirmed_removed"},    "reappeared": {"found", "indirect_exposure"},    "human_task_queued": {        "found", "indirect_exposure", "action_selected", "submitted", "verification_pending",        "awaiting_processing", "confirmed_removed", "blocked",    },    # blocked: automated tools (web_extract/proxyless browser) couldn't read the site. A later pass    # -- a stealth/cloud browser OR guiding the operator's own (residential) browser -- can resolve it    # to any real scan verdict, so blocked reaches not_found / indirect_exposure too, not just found.    # blocked -> human_task_queued: some blocked sites need an operator step to proceed at all    # (face-recognition sites needing a selfie/gov-ID, etc.), so route them to the digest.    "blocked": {"searching", "found", "not_found", "indirect_exposure", "action_selected",                "human_task_queued"},}  def now() -> str:    return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")  def load(subject_id: str) -> dict:    return storage.read_json(paths.ledger_path(subject_id), {}) or {}  def save(subject_id: str, ledger: dict) -> Path:    return storage.write_json(paths.ledger_path(subject_id), ledger)  def new_case(subject_id: str, broker_id: str) -> dict:    return {        "case_id": f"case_{subject_id}_{broker_id}",        "subject_id": subject_id,        "broker_id": broker_id,        "state": "new",        "found": None,        "evidence": {},        "disclosure_log": [],        "history": [],    }  def get_case(subject_id: str, broker_id: str) -> dict:    return load(subject_id).get(broker_id) or new_case(subject_id, broker_id)  def can_transition(old: str, new: str) -> bool:    return new == old or new in TRANSITIONS.get(old, set())  def transition(subject_id: str, broker_id: str, new_state: str, **fields) -> dict:    if new_state not in STATES:        raise ValueError(f"unknown state {new_state!r}")    # Lock the whole load-modify-save so a concurrent cron re-scan / other tenant    # can't read a stale ledger and clobber this transition.    with storage.locked(paths.ledger_path(subject_id)):        ledger = load(subject_id)        case = ledger.get(broker_id) or new_case(subject_id, broker_id)        old = case.get("state", "new")        if not can_transition(old, new_state):            raise ValueError(f"illegal transition {old!r} -> {new_state!r} for broker {broker_id!r}")        case["state"] = new_state        for key, value in fields.items():            case[key] = value        stamp = now()        case.setdefault("history", []).append({"at": stamp, "from": old, "to": new_state})        ledger[broker_id] = case        save(subject_id, ledger)        storage.append_jsonl(            paths.audit_path(subject_id),            {"at": stamp, "broker_id": broker_id, "event": "transition", "from": old, "to": new_state},        )        return case  DEFAULT_PROCESSING_DAYS = 14   # when a broker record doesn't state est_processing_daysVERIFICATION_POLL_DAYS = 1     # how soon to re-poll for an unarrived verification email  def _plus_days(days: int, start: str | None = None) -> str:    base = _dt.datetime.strptime(start, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=_dt.timezone.utc) \        if start else _dt.datetime.now(_dt.timezone.utc)    return (base + _dt.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")  def followup_fields(new_state: str, broker: dict | None = None,                    dossier: dict | None = None) -> dict:    """Auto-scheduling stamps for a transition, so nobody has to remember follow-ups.     submitted / awaiting_processing -> recheck after the broker's stated processing window;    verification_pending            -> re-poll the inbox quickly;    confirmed_removed               -> periodic reappearance re-scan per subject preference.    """    if new_state in ("submitted", "awaiting_processing"):        days = ((broker or {}).get("optout") or {}).get("est_processing_days") or DEFAULT_PROCESSING_DAYS        return {"next_recheck_at": _plus_days(int(days))}    if new_state == "verification_pending":        return {"next_recheck_at": _plus_days(VERIFICATION_POLL_DAYS)}    if new_state == "confirmed_removed":        interval = ((dossier or {}).get("preferences") or {}).get("rescan_interval_days") or 120        return {"removal_confirmed_at": now(), "next_recheck_at": _plus_days(int(interval))}    return {}  def due(subject_id: str, at: str | None = None, ledger: dict | None = None) -> list[dict]:    """Cases whose next_recheck_at has arrived - the autonomous follow-up queue."""    stamp = at or now()    out = []    for case in (ledger if ledger is not None else load(subject_id)).values():        when = case.get("next_recheck_at")        if when and when <= stamp:            out.append(case)    out.sort(key=lambda c: c.get("next_recheck_at") or "")    return out  def log_disclosure(subject_id: str, broker_id: str, fields: list[str], channel: str) -> dict:    """Record exactly which PII field *names* were disclosed to a broker."""    with storage.locked(paths.ledger_path(subject_id)):        ledger = load(subject_id)        case = ledger.get(broker_id) or new_case(subject_id, broker_id)        stamp = now()        record = {"at": stamp, "fields": sorted(fields), "channel": channel}        case.setdefault("disclosure_log", []).append(record)        ledger[broker_id] = case        save(subject_id, ledger)        storage.append_jsonl(            paths.audit_path(subject_id),            {"at": stamp, "broker_id": broker_id, "event": "disclosure",             "fields": record["fields"], "channel": channel},        )        return record