scripts/autopilot.py
scripts/autopilot.pyBrowse 56 files
5,019 tokens
21,978 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1"""Autonomous action queue: what should the agent do RIGHT NOW for this subject?2 3`next_actions` turns (dossier, broker DB, config, ledger) into an ordered queue of4concrete agent actions plus a human digest. The agent's whole run becomes a loop:5 6 while True:7 q = pdd.py next <subject>8 if not q["actions"]: break9 execute each action, record outcomes10 present q["human_digest"] once; schedule cron at q["next_wake_at"]11 12Policy (cfg["autonomy"]):13 full - intake consent is standing authorization; T0-T2 agent actions are14 executed without pausing. Humans appear only in the digest.15 assisted - same queue, but every submission action carries confirm_first=True.16 17The queue is deterministic and side-effect free: it never mutates the ledger, it18only reads. Executing + recording stays with the agent (and the record command).19"""20from __future__ import annotations21 22import datetime as _dt23import os24from pathlib import Path25 26import brokers as brokers_mod27import emailer28import ledger as ledger_mod29import paths30import registry31import tiers32 33CACHE_STALE_DAYS = 7 # refresh the live broker list after this34FANOUT_THRESHOLD = 8 # above this many unscanned brokers, use delegate_task fan-out35 36# States with nothing left to do (absent a due recheck).37_TERMINAL = {"not_found", "confirmed_removed"}38_IN_FLIGHT = {"submitted", "verification_pending", "awaiting_processing"}39 40 41def cache_age_days(now: float | None = None) -> float | None:42 """Age of the live BADBOOL cache in days, or None if never pulled."""43 p: Path = paths.brokers_cache_path()44 if not p.exists():45 return None46 now = now if now is not None else _dt.datetime.now().timestamp()47 return max(0.0, (now - p.stat().st_mtime) / 86400.0)48 49 50def _now_iso() -> str:51 return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")52 53 54def _min_future_recheck(ledger: dict, at: str) -> str | None:55 future = [c.get("next_recheck_at") for c in ledger.values()56 if c.get("next_recheck_at") and c["next_recheck_at"] > at]57 return min(future) if future else None58 59 60def _digest(broker_row: dict, reason: str, steps: list[str], prep: list[str] | None = None) -> dict:61 return {62 "broker_id": broker_row.get("broker_id"),63 "broker_name": broker_row.get("broker_name"),64 "reason": reason,65 "agent_prep": prep or [], # commands the agent runs BEFORE handing this to the human66 "steps": steps, # what the human actually does67 "withhold": ["SSN", "full driver's-license / passport numbers"],68 }69 70 71def request_kind(dossier: dict, allowed: list[str] | None = None) -> str:72 """Pick the honest legal basis for a deletion request from the subject's residency.73 74 ccpa only for California residents, gdpr only for EU/UK residents, generic otherwise.75 `allowed` (from the broker's deletion.kinds) can restrict DOWN to generic but never76 upgrades to a law the subject can't truthfully claim.77 """78 res = (dossier.get("residency_jurisdiction") or "US").upper()79 if res.startswith("US-CA"):80 kind = "ccpa"81 elif res.startswith(("EU", "UK", "GB")):82 kind = "gdpr"83 else:84 kind = "generic"85 if allowed and kind not in allowed and "generic" in allowed:86 kind = "generic"87 return kind88 89 90_HUMAN_GATES = ("gov_id", "fax", "mail", "phone_voice", "phone_callback", "account")91 92 93def _email_lane(row: dict) -> tuple[str | None, str]:94 """(address, why) for the autonomous email lane of this broker, if one exists.95 96 Lane rules:97 1. the broker's primary opt-out method IS email;98 2. the record marks its deletion lane email-preferred (deletion.via == "email");99 3. RESCUE: the primary flow is human-gated (gov ID / fax / phone / account) but a100 right-to-delete email exists - the email lane restores full autonomy (this is the101 verified Whitepages pattern: privacyrequest@ accepts requests precisely so people102 don't have to do the phone-callback tool).103 """104 deletion = row.get("deletion") or {}105 req = row.get("optout_requires") or {}106 if row.get("method") == "email":107 addr = row.get("optout_email") or deletion.get("email")108 return (addr, "primary opt-out method is email") if addr else (None, "")109 if deletion.get("via") == "email" and deletion.get("email"):110 return deletion["email"], "record prefers the right-to-delete email lane"111 if (row.get("tier") == "T3" or any(req.get(k) for k in _HUMAN_GATES)) and deletion.get("email"):112 return deletion["email"], "rescue: primary flow is human-gated; deletion email restores autonomy"113 return None, ""114 115 116def _optout_action(row: dict, playbook: dict[str, dict], subject_id: str, dossier: dict,117 email_mode: str, smtp_ok: bool, confirm_first: bool) -> tuple[dict | None, dict | None]:118 """Map one actionable `found` row to (agent_action, human_digest_entry).119 120 Routing order maximizes autonomy: (1) the email lane (primary email method, preferred121 right-to-delete email, or rescue from a human-gated form) beats everything when SMTP is122 up; (2) genuinely human-only flows go to the digest; (3) web forms are driven with the123 record's own field-verified playbook steps.124 """125 bid = row["broker_id"]126 req = row.get("optout_requires") or {}127 tier = row.get("tier")128 deletion = row.get("deletion") or {}129 130 # 1) The autonomous EMAIL LANE (right-to-delete by email + confirm the reply).131 # Autonomous when SMTP is configured (programmatic/alias) OR in browser mode (agent sends via132 # the operator's logged-in webmail; no password needed).133 email_addr, lane_why = _email_lane(row)134 can_email = (email_mode in ("programmatic", "alias") and smtp_ok) or email_mode == "browser"135 if email_addr and can_email:136 kind = request_kind(dossier, deletion.get("kinds"))137 via = "browser" if email_mode == "browser" else "smtp"138 then = ("send-email records it + returns a recipient-locked payload; compose and send it in "139 "the operator's webmail via browser_*, then `verify-link` on the reply and open the link"140 if via == "browser" else141 "state auto-records as submitted; poll-verification picks up their verification reply, "142 "open its link, then record")143 return {144 "type": "optout_email_send",145 "broker_id": bid, "broker_name": row.get("broker_name"), "tier": tier,146 "confirm_first": confirm_first, "send_via": via,147 "to": email_addr, "kind": kind, "why": lane_why,148 "command": f"python3 scripts/pdd.py send-email {subject_id} {bid} --kind {kind} "149 f"--to {email_addr} --listing <confirmed-url>",150 "then": then,151 }, None152 if row.get("method") == "email":153 return None, _digest(row, "email opt-out (draft mode: a human must hit send)",154 ["Send the rendered draft from your own mail client",155 f"Then: python3 scripts/pdd.py record {subject_id} {bid} submitted "156 f"--disclosed contact_email --channel email"],157 prep=[f"python3 scripts/pdd.py render-email {subject_id} {bid} --listing <confirmed-url>"])158 159 # 2) Genuinely human-only work goes to the digest (no email lane could rescue it).160 if tier == "T3":161 return None, _digest(row, "human-only opt-out (gov ID / fax / mail / voice phone)",162 [f"Follow the broker's process at {row.get('optout_url') or row.get('optout_email')}",163 "Provide only the fields the listing already shows; cross out ID numbers on any document"])164 if req.get("phone_callback"):165 return None, _digest(row, "phone-callback verification (operator must be on the phone)",166 [f"Open {row.get('optout_url')} and submit with only the planned fields",167 "Answer the automated call and enter the 4-digit code to finish"],168 prep=[f"python3 scripts/pdd.py plan {subject_id} --batch # confirm fields first"])169 if req.get("account"):170 return None, _digest(row, "requires creating/holding an account with the broker",171 [f"Create/log in at {row.get('optout_url')} and submit the opt-out",172 "Use the subject's contact email; no extra PII beyond the planned fields"])173 174 # 3) web_form: drive the browser with the record's own playbook steps.175 steps = (playbook.get(bid) or {}).get("steps") or list(row.get("optout_playbook") or []) \176 or tiers.synthesize_steps(row)177 action = {178 "type": "optout_web_form",179 "broker_id": bid, "broker_name": row.get("broker_name"), "tier": tier,180 "confirm_first": confirm_first,181 "optout_url": row.get("optout_url"),182 "clears_children": row.get("clears_children") or [],183 "steps": steps,184 "after": f"python3 scripts/pdd.py record {subject_id} {bid} submitted "185 f"--disclosed <field>... --channel web_form",186 }187 if deletion:188 if deletion.get("prefer", True):189 action["prefer_deletion"] = ("this record has a right-to-delete lane -- complete the "190 "DELETION flow, not just suppression"191 + (f" ({deletion.get('notes')})" if deletion.get("notes") else ""))192 else:193 # Some brokers invert the usual rule: deleting the account removes suppressions and194 # does not stop public-records re-listing (e.g. PeopleConnect). Suppress and maintain.195 action["prefer_suppression"] = (deletion.get("notes")196 or "suppression (maintained) is what removes you here; "197 "deleting undoes it and does not stop re-listing")198 if req.get("captcha"):199 action["note"] = ("CAPTCHA-gated: attempt with the configured browser backend once; if it "200 "does not clear, record blocked (do NOT retry-loop or bypass)")201 return action, None202 203 204def next_actions(dossier: dict, brokers_list: list[dict], cfg: dict,205 ledger: dict | None = None, env: dict | None = None) -> dict:206 env = os.environ if env is None else env207 ledger = ledger or {}208 subject_id = dossier.get("subject_id", "")209 autonomy = cfg.get("autonomy", "full")210 confirm_first = autonomy == "assisted"211 email_mode = cfg.get("email_mode", "draft_only")212 mail = emailer.available(env)213 at = _now_iso()214 215 batch = tiers.batch_plan(dossier, brokers_list, cfg, ledger,216 browser_clears_captcha=cfg.get("browser_backend") == "browserbase"217 or bool(env.get("BROWSERBASE_API_KEY")))218 groups = batch["groups"]219 playbook = {p["broker_id"]: p for p in batch.get("parent_playbook") or []}220 by_id = {b.get("id"): b for b in brokers_list}221 222 actions: list[dict] = []223 digest: list[dict] = []224 225 # 0) keep the broker DB fresh (autonomously)226 age = cache_age_days()227 if age is None or age > CACHE_STALE_DAYS:228 actions.append({229 "type": "refresh_brokers",230 "why": "live broker cache missing" if age is None else f"cache is {age:.0f} days old",231 "command": "python3 scripts/pdd.py refresh-brokers",232 })233 234 # 0b) DROP one-shot: for a CA resident, ONE request deletes from every registered235 # broker (the whole CA Data Broker Registry) -- the highest-leverage removal there is.236 registry_recs = brokers_mod.load_registry_cache()237 residency = (dossier.get("residency_jurisdiction") or "US").upper()238 drop_filed = bool((dossier.get("preferences") or {}).get("drop_filed_at"))239 if registry_recs and residency.startswith("US-CA") and not drop_filed:240 actions.append({241 "type": "drop_submit",242 "one_shot": True,243 "registry_count": len(registry_recs),244 "url": registry.DROP_URL,245 "command": f"python3 scripts/pdd.py drop {subject_id}",246 "why": f"CA resident: one DROP request deletes from all {len(registry_recs)} registered "247 "data brokers at once (superset of what commercial services cover).",248 "after": f"python3 scripts/pdd.py drop {subject_id} --filed",249 })250 251 # 1) Phase 1 crawl: everything unscanned (read-only, parallel-safe)252 unscanned = groups.get("unscanned") or []253 if unscanned:254 ids = [r["broker_id"] for r in unscanned]255 if len(ids) > FANOUT_THRESHOLD:256 actions.append({257 "type": "fanout_scan",258 "broker_ids": ids,259 "command": f"python3 scripts/pdd.py fanout {subject_id}",260 "how": "spawn ONE delegate_task subagent per batch IN PARALLEL with each batch's brief; "261 "parent re-verifies key `found` claims before trusting them",262 })263 else:264 actions.append({265 "type": "scan_inline",266 "broker_ids": ids,267 "command": f"python3 scripts/pdd.py plan {subject_id}",268 "how": "run every search_vector per broker via the methods.md ladder "269 "(web_extract -> site: probe -> browser), record a verdict per broker",270 })271 272 # 2) in-flight email verifications: poll the inbox (or hand to the human in draft mode)273 for st in ("submitted", "verification_pending"):274 for bid, case in sorted(ledger.items()):275 if case.get("state") != st:276 continue277 broker = by_id.get(bid) or {}278 if not ((broker.get("optout") or {}).get("requires") or {}).get("email_verification"):279 continue280 if mail["imap"]:281 actions.append({282 "type": "poll_verification", "via": "imap",283 "broker_id": bid,284 "command": f"python3 scripts/pdd.py poll-verification {subject_id} --broker {bid}",285 "then": "browser_navigate the returned link IN THE SAME AGENT BROWSER (sessions are "286 "browser-bound), complete the flow, then record: awaiting_processing",287 })288 elif email_mode == "browser":289 actions.append({290 "type": "poll_verification", "via": "browser", "broker_id": bid,291 "how": "open the broker's confirmation email in the operator's logged-in webmail "292 f"(browser_*), then `python3 scripts/pdd.py verify-link {subject_id} {bid} "293 "--text '<email body>'` to score the link, browser_navigate it in the SAME "294 "browser, then record awaiting_processing",295 })296 else:297 digest.append(_digest(298 {"broker_id": bid, "broker_name": (broker.get("name") or bid)},299 "verification email must be opened by a human (draft mode, no inbox access)",300 ["Open the broker's verification email in the subject's inbox and click the link",301 f"Then: python3 scripts/pdd.py record {subject_id} {bid} awaiting_processing"]))302 303 # 3) due rechecks: processing windows elapsed / reappearance sweeps304 for case in ledger_mod.due(subject_id, at=at, ledger=ledger):305 bid = case.get("broker_id")306 st = case.get("state")307 if st in ("awaiting_processing", "confirmed_removed"):308 actions.append({309 "type": "verify_removal",310 "broker_id": bid,311 "why": "processing window elapsed" if st == "awaiting_processing" else "periodic reappearance re-scan",312 "how": "re-run this broker's search_vectors; if gone record confirmed_removed; "313 "if still listed record reappeared and requeue the opt-out",314 })315 elif st in ("submitted", "verification_pending") and not mail["imap"]:316 pass # already covered by the digest entry above317 318 # 4) Phase 2 opt-outs: parents first (batch_plan already ordered them)319 for row in groups.get("found") or []:320 action, task = _optout_action(row, playbook, subject_id, dossier,321 email_mode, mail["smtp"], confirm_first)322 if action:323 actions.append(action)324 if task:325 digest.append(task)326 327 # 5) indirect exposure: targeted delete-my-PII requests328 for row in groups.get("indirect_exposure") or []:329 bid = row["broker_id"]330 has_email = bool(row.get("optout_email") or (row.get("deletion") or {}).get("email"))331 if not has_email and row.get("optout_url"):332 # No email lane (e.g. ThatsThem is web-form-only): drive the opt-out FORM, submitting333 # ONLY the subject's own identifiers to scrub from the third party's record.334 actions.append({335 "type": "indirect_web_form",336 "broker_id": bid, "confirm_first": confirm_first,337 "optout_url": row.get("optout_url"),338 "steps": [f"browser_navigate {row.get('optout_url')}",339 "submit ONLY the subject's own identifiers (the fields the form requires) to "340 "remove them from the third party's record; disclose nothing extra",341 "confirm the success state, screenshot into evidence/"],342 "after": f"python3 scripts/pdd.py record {subject_id} {bid} submitted --channel web_form",343 })344 elif (email_mode in ("programmatic", "alias") and mail["smtp"]) or email_mode == "browser":345 actions.append({346 "type": "indirect_email_send",347 "broker_id": bid, "confirm_first": confirm_first,348 "send_via": "browser" if email_mode == "browser" else "smtp",349 "command": f"python3 scripts/pdd.py send-email {subject_id} {bid} --kind ccpa_indirect "350 f"--listing <third-party-listing-url>",351 })352 else:353 digest.append(_digest(row, "indirect-exposure request (draft mode: a human must hit send)",354 ["Send the rendered ccpa_indirect draft",355 f"Then: python3 scripts/pdd.py record {subject_id} {bid} submitted "356 f"--disclosed contact_email --channel email"],357 prep=[f"python3 scripts/pdd.py render-email {subject_id} {bid} "358 f"--kind ccpa_indirect --listing <url>"]))359 360 # 6) blocked sites: stealth pass if we have one, else the operator-browser path361 blocked = groups.get("blocked") or []362 if blocked:363 ids = [r["broker_id"] for r in blocked]364 if bool(env.get("BROWSERBASE_API_KEY")):365 actions.append({366 "type": "stealth_rescan",367 "broker_ids": ids,368 "how": "retry these with the cloud/stealth browser backend, then record real verdicts",369 })370 else:371 for r in blocked:372 digest.append(_digest(r, "site blocks automated access (anti-bot); a human browser gets through",373 ["Open the paste-ready search URL from `plan` in your everyday browser",374 "Report the verdict (or a screenshot) back to the agent",375 f"Agent records: python3 scripts/pdd.py record {subject_id} "376 f"{r['broker_id']} <found|not_found|indirect_exposure>"]))377 378 # 7) anything already parked as a human task379 for bid, case in sorted(ledger.items()):380 if case.get("state") == "human_task_queued":381 broker = by_id.get(bid) or {}382 digest.append(_digest({"broker_id": bid, "broker_name": broker.get("name") or bid},383 case.get("human_task_reason") or "queued manual step",384 ["See `pdd.py tasks` for the exact steps recorded with this case"]))385 386 # registry coverage summary (breadth beyond the scannable people-search sites)387 coverage = None388 if registry_recs:389 coverage = {390 "people_search_sites": len(brokers_list),391 "registered_data_brokers": len(registry_recs),392 "worked_via": "CA DROP one-shot" if residency.startswith("US-CA") else "targeted CCPA/GDPR email",393 }394 if not residency.startswith("US-CA"):395 coverage["note"] = ("DROP is CA-only; for this subject the registry is covered by targeted "396 "CCPA/GDPR deletion emails (`registry --search` then `send-email`), "397 "not a single portal request.")398 elif drop_filed:399 coverage["note"] = "DROP already filed; registry deletions are in the brokers' hands."400 401 next_wake = _min_future_recheck(ledger, at)402 return {403 "subject": subject_id,404 "autonomy": autonomy,405 "phase": batch.get("phase"),406 "counts": batch.get("counts"),407 "actions": actions,408 "human_digest": digest,409 "coverage": coverage,410 "done_for_now": not actions,411 "fully_done": not actions and not digest and not next_wake,412 "next_wake_at": next_wake,413 "note": ("assisted mode: pause for operator confirmation on every action with confirm_first=true"414 if confirm_first else415 "full autonomy: recorded intake consent authorizes these submissions; do not pause. "416 "Present human_digest ONCE at the end of the run, not per item."),417 }418