mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-16 14:32:34 +00:00
from a live run (NY subject, 43 brokers): - fanout default 8->5 (8+ batches time out) - setup/doctor read $HERMES_HOME/.env so creds hermes already loads are detected - new `show <subject> <broker>`: reads back case state+evidence for cheap parent re-verify - intelius: requires.dob + 5-step guided-mode gate; planner pre-warns when dob is missing - rehold.json: property-record != PII (an address-only match is not_found, not removable) - tps/fps: match_signal_notes tell the scanner to ignore SEO-templated titles - methods.md: browser backends (scan vs execute + operator chrome over CDP), property/SEO callouts - doctor: warn when browser email-mode pairs with a cloud scan backend (needs operator chrome/CDP) - ledger: found->not_found retract (false-positive), blocked->human_task_queued - autopilot: indirect-exposure web-form fallback; drop a stray f-string tests: standalone 92 pass; ruff clean.
170 lines
7.8 KiB
Python
170 lines
7.8 KiB
Python
"""Case ledger: opt-out state machine + append-only audit log.
|
|
|
|
A "case" is one (subject x broker) record. State changes are validated against
|
|
TRANSITIONS and mirrored into audit.jsonl so every action is auditable.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as _dt
|
|
from pathlib import Path
|
|
|
|
import paths
|
|
import 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_days
|
|
VERIFICATION_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
|