scripts/pdd.py
scripts/pdd.pyBrowse 56 files
11,283 tokens
47,215 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""unbroker - deterministic CLI helper.3 4The Hermes agent orchestrates scanning and opt-out submission with native tools5(`web_extract`, `browser_navigate`, email mechanisms). THIS CLI owns the6deterministic state: config, dossiers + consent, the broker DB, tier planning,7the ledger + audit log, draft/template rendering, and reports.8 9Run it through the `terminal` tool (it can read PII files under HERMES_HOME);10do NOT run it through `execute_code` (that sandbox scrubs env and redacts output).11 12Examples:13 python pdd.py setup14 python pdd.py intake --full-name "Jane Q. Public" --email jane@example.com \15 --city Oakland --state CA --residency US-CA --consent --consent-method self16 python pdd.py plan sub_xxxx --priority crucial17 python pdd.py record sub_xxxx spokeo found --found true \18 --evidence '{"listing_urls":["https://www.spokeo.com/..."]}'19 python pdd.py render-email sub_xxxx spokeo --listing https://www.spokeo.com/...20 python pdd.py status sub_xxxx21"""22from __future__ import annotations23 24import argparse25import json26import os27import sys28from pathlib import Path29 30sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))31 32import autopilot # noqa: E40233import badbool # noqa: E40234import cdp # noqa: E40235import brokers as brokers_mod # noqa: E40236import config as config_mod # noqa: E40237import crypto # noqa: E40238import dossier as dossier_mod # noqa: E40239import email_modes # noqa: E40240import emailer # noqa: E40241import ledger as ledger_mod # noqa: E40242import legal # noqa: E40243import paths as paths_mod # noqa: E40244import registry # noqa: E40245import report as report_mod # noqa: E40246import tiers # noqa: E40247 48 49def _out(obj) -> None:50 print(json.dumps(obj, indent=2, ensure_ascii=False))51 52 53def _require_subject(subject_id: str) -> dict:54 d = dossier_mod.load(subject_id)55 if not d:56 sys.exit(f"error: unknown subject {subject_id!r} (run `intake` first)")57 return d58 59 60def cmd_setup(args) -> None:61 if getattr(args, "auto", False):62 # Autonomous path: detect capabilities and pick the most autonomous valid config without63 # asking anyone. Read creds from $HERMES_HOME/.env too (the terminal shell doesn't export64 # them). Explicit flags still win below.65 cfg = config_mod.auto_configure(env=config_mod.dotenv_env())66 else:67 cfg = config_mod.load_config()68 for key in ("autonomy", "email_mode", "browser_backend", "tracker_backend", "encryption"):69 val = getattr(args, key)70 if val:71 cfg[key] = val72 if cfg.get("encryption") == "age":73 if not crypto.age_available():74 sys.exit("error: encryption=age requested but `age`/`age-keygen` not found. "75 "Install age (e.g. `brew install age`) or use `--encryption none`.")76 crypto.ensure_identity() # generate the key now so encryption is actually engaged77 path = config_mod.save_config(cfg)78 migrated = _migrate_subjects() # rewrite existing dossiers/ledgers into the new at-rest format79 out = {80 "config_path": str(path),81 "config": cfg,82 "encryption_engaged": crypto.is_engaged(),83 "detected_upgrades": config_mod.detect_capabilities(),84 "migrated_subjects": migrated,85 "note": "Defaults are easiest-first (draft email, auto browser, local tracker, no encryption). "86 "Pass flags to opt into upgrades, then run `doctor` for a readiness summary.",87 }88 if cfg.get("encryption") == "age":89 out["age_identity"] = str(crypto.identity_path())90 _out(out)91 92 93def _migrate_subjects() -> int:94 """Re-save each subject's dossier + ledger so they match the current at-rest format."""95 sd = paths_mod.subjects_dir()96 if not sd.exists():97 return 098 n = 099 for child in sorted(sd.iterdir()):100 if not child.is_dir():101 continue102 sid = child.name103 d = dossier_mod.load(sid)104 if d is not None:105 dossier_mod.save(d)106 n += 1107 led = ledger_mod.load(sid)108 if led:109 ledger_mod.save(sid, led)110 return n111 112 113def _check_writable(path) -> bool:114 try:115 path.mkdir(parents=True, exist_ok=True)116 probe = path / ".write_test"117 probe.write_text("x", encoding="utf-8")118 probe.unlink()119 return True120 except OSError:121 return False122 123 124def cmd_doctor(args) -> None:125 import platform126 127 cfg = config_mod.load_config()128 caps = config_mod.detect_capabilities(config_mod.dotenv_env()) # see creds in $HERMES_HOME/.env too129 data = paths_mod.data_dir()130 writable = _check_writable(data)131 curated = len(brokers_mod._load_curated())132 live = len(brokers_mod.load_live_cache())133 total = len(brokers_mod.load_all())134 135 L = ["unbroker - readiness check", "=" * 42,136 f"Python : {platform.python_version()}",137 f"Data dir : {data} ({'writable' if writable else 'NOT writable'})",138 f"Config : autonomy={cfg.get('autonomy', 'full')} email={cfg['email_mode']} "139 f"browser={cfg['browser_backend']} "140 f"tracker={cfg['tracker_backend']} encryption={cfg['encryption']}",141 f"Brokers : {total} available ({curated} curated + {live} live"142 + ("" if live else ", run `refresh-brokers` to expand to ~50") + ")",143 "", "Opt-in upgrades:"]144 rows = [145 ("Cloud browser (Browserbase) *RECOMMENDED*", caps["browserbase"],146 "default backend: clears soft CAPTCHAs (Turnstile/hCaptcha) -> more T1", "set BROWSERBASE_API_KEY"),147 ("Email auto (AgentMail)", caps["agentmail"],148 "send + auto-verify, per-broker aliases (Mode B/C)", "install agentmail skill / set AGENTMAIL_API_KEY"),149 ("Email send (CLI SMTP)", caps["smtp_send"],150 "`send-email` delivers opt-outs itself (Mode B)", "set EMAIL_ADDRESS / EMAIL_PASSWORD (+ EMAIL_SMTP_HOST)"),151 ("Verify-link poll (CLI IMAP)", caps["imap_read"],152 "`poll-verification` reads confirmation links itself", "set EMAIL_ADDRESS / EMAIL_PASSWORD (+ EMAIL_IMAP_HOST)"),153 ("Google Sheets tracker", caps["google_workspace"],154 "shared status dashboard", "set up the google-workspace skill"),155 ]156 for name, ok, enables, how in rows:157 L.append(f" [{'ON ' if ok else 'off'}] {name:<28} {enables}")158 if not ok:159 L.append(f" enable: {how}")160 161 # At-rest encryption: report TRUE engagement (configured + key present), not just binary presence.162 engaged = crypto.is_engaged()163 L.append(f" [{'ON ' if engaged else 'off'}] {'At-rest encryption (age)':<28} "164 "encrypts dossiers + ledgers on disk")165 if engaged:166 L.append(f" key: {crypto.identity_path()} (0600) - guards casual/backup/commit "167 "exposure, NOT a full-HERMES_HOME read")168 elif cfg["encryption"] == "age":169 L.append(" WARNING: encryption=age is SET but NOT engaged (age binary or key missing);"170 " dossiers would be PLAINTEXT")171 elif caps["age"]:172 L.append(" off - dossiers are plaintext (0600). enable: `setup --encryption age`")173 else:174 L.append(" off - dossiers are plaintext (0600). install `age` first to enable")175 176 L += ["", "Verdict:", " Ready now in DRAFT mode (no setup needed): scan brokers, draft opt-out",177 " emails for you to send, and track everything in the ledger."]178 if caps["browserbase"]:179 L.append(" Cloud browser ON (recommended default): soft/managed CAPTCHAs "180 "(Turnstile/hCaptcha) clear automatically -> those brokers stay T1.")181 else:182 L.append(" No cloud browser: set BROWSERBASE_API_KEY (the recommended default) so soft "183 "CAPTCHAs clear automatically; without it those brokers drop to T2 (human tasks).")184 if cfg["email_mode"] == "draft_only":185 L.append(" Email is draft-only: you send drafts + click verify links. For hands-off email "186 "WITHOUT storing a password, run `setup --email-mode browser` (agent sends + opens "187 "verify links via your logged-in webmail); or set EMAIL_* for SMTP/IMAP.")188 elif cfg["email_mode"] == "browser":189 L.append(" Email mode: browser (no password) - the agent sends opt-outs and opens verify "190 "links via the operator's logged-in webmail. This needs Hermes pointed at the "191 "operator's OWN Chrome over CDP (launch with --remote-debugging-port=9222 "192 "--user-data-dir=~/.hermes/chrome-debug, signed into the webmail once); else it falls "193 "back to drafts. Run `pdd.py cdp` to launch it (or `pdd.py cdp --print` for the command). "194 "See methods.md 'Browser backends'.")195 cloud_scan = cfg.get("browser_backend") == "browserbase" or (196 cfg.get("browser_backend") == "auto" and caps.get("browserbase"))197 if cloud_scan:198 L.append(" NOTE: your scan backend is a cloud browser (Browserbase). It is great for "199 "Phase-1 scanning but CANNOT be the browser that sends webmail (no inbox session) "200 "and is itself Cloudflare/DataDome-gated on session-bound gates (e.g. PeopleConnect). "201 "For Phase-2 email/verify, launch the operator's Chrome over CDP: `pdd.py cdp`.")202 if not crypto.is_engaged():203 L.append(" Storage: dossiers are PLAINTEXT JSON (0600 under HERMES_HOME). "204 "Run `setup --encryption age` for at-rest encryption.")205 if not live:206 L.append(" Next: run `refresh-brokers` to load the full broker list.")207 208 # Freshness: warn when cached lists / curated mechanics are going stale (silent broker rot).209 import time as _time210 STALE_CACHE_DAYS, STALE_VERIFY_DAYS = 30, 180211 212 def _age_days(p) -> float | None:213 try:214 return (_time.time() - p.stat().st_mtime) / 86400.0215 except OSError:216 return None217 218 fresh = []219 for label, p in [("BADBOOL", paths_mod.brokers_cache_path()),220 ("CA registry", paths_mod.registry_cache_path())]:221 age = _age_days(p)222 if age is None:223 fresh.append(f"{label}: not pulled")224 elif age > STALE_CACHE_DAYS:225 fresh.append(f"{label}: {age:.0f}d old (stale, re-pull)")226 stale_curated = documented = 0227 for b in brokers_mod._load_curated():228 conf = b.get("confidence")229 lv = b.get("last_verified")230 if conf == "documented" or not lv:231 documented += 1232 continue233 try:234 if (_time.time() - _time.mktime(_time.strptime(lv, "%Y-%m-%d"))) / 86400.0 > STALE_VERIFY_DAYS:235 stale_curated += 1236 except (ValueError, TypeError):237 pass238 if fresh:239 L.append(" Freshness: " + "; ".join(fresh) + " (run `refresh-brokers`).")240 if stale_curated or documented:241 L.append(f" Freshness: {stale_curated} curated broker(s) last-verified >{STALE_VERIFY_DAYS}d ago; "242 f"{documented} documented broker(s) awaiting first-use verification.")243 print("\n".join(L))244 245 246def cmd_cdp(args) -> None:247 """Launch (or detect) the operator's Chrome over CDP for Phase-2 browser + webmail work.248 249 A cloud browser cannot send the operator's webmail or clear session-bound gates; this points250 Hermes at the operator's real Chrome on a dedicated debug profile (see methods.md).251 """252 import shlex253 import time254 255 port = args.port256 profile = Path(args.profile).expanduser() if args.profile else cdp.default_profile()257 258 live = cdp.endpoint_status(port)259 if live:260 _out({"running": True, "endpoint": f"127.0.0.1:{port}",261 "browser": live.get("Browser"),262 "webSocketDebuggerUrl": live.get("webSocketDebuggerUrl"),263 "note": "a debuggable browser is already listening; point Hermes's browser tools at "264 f"127.0.0.1:{port} and make sure the operator's webmail is signed in in THAT browser."})265 return266 267 if getattr(args, "check", False):268 _out({"running": False, "endpoint": f"127.0.0.1:{port}",269 "note": f"no debuggable browser here yet; run `pdd.py cdp --port {port}` (no --check) to launch one."})270 return271 272 browser = cdp.find_browser(args.browser)273 if not browser:274 _out({"running": False, "error": "no Chrome/Chromium-family browser found",275 "fix": "install Google Chrome, or pass --browser /path/to/chrome (or a command on PATH)"})276 return277 278 cmd = cdp.launch_command(browser, port, profile)279 if getattr(args, "print_only", False):280 _out({"running": False, "browser": browser, "profile": str(profile), "command": cmd,281 "shell": " ".join(shlex.quote(c) for c in cmd),282 "note": "run this yourself to launch the debug browser, then sign into your webmail once."})283 return284 285 pid = cdp.launch(browser, port, profile)286 live = None287 for _ in range(20): # give Chrome a few seconds to open the debug port288 live = cdp.endpoint_status(port)289 if live:290 break291 time.sleep(0.5)292 _out({"running": bool(live), "launched_pid": pid, "browser": browser,293 "profile": str(profile), "endpoint": f"127.0.0.1:{port}",294 "webSocketDebuggerUrl": (live or {}).get("webSocketDebuggerUrl"),295 "next": ([f"point Hermes's browser tools at 127.0.0.1:{port} (CDP)",296 "in the launched browser, sign into the operator's webmail ONCE (dedicated debug profile)",297 "then run email/verify flows in browser mode -- they use this logged-in session"]298 if live else299 ["browser launched but the debug port has not answered yet; give it a few seconds, then "300 f"re-run `pdd.py cdp --check --port {port}`"])})301 302 303def cmd_intake(args) -> None:304 if args.json:305 data = json.loads(Path(args.json).read_text(encoding="utf-8"))306 identity = data["identity"]307 consent = data.get("consent", {})308 residency = data.get("residency_jurisdiction", "US")309 prefs = data.get("preferences")310 else:311 if not args.full_name:312 sys.exit("error: --full-name (or --json) is required")313 identity = {"full_name": args.full_name, "emails": args.email or [], "phones": args.phone or []}314 if args.alias:315 identity["also_known_as"] = args.alias316 if args.dob:317 identity["date_of_birth"] = args.dob318 addr = {k: v for k, v in {"line1": args.street, "city": args.city,319 "state": args.state, "postal": args.postal}.items() if v}320 if addr:321 identity["current_address"] = addr322 priors = []323 for loc in args.prior_location or []:324 parts = [p.strip() for p in loc.split(",") if p.strip()]325 if not parts:326 continue327 entry = {"city": parts[0]}328 if len(parts) > 1:329 entry["state"] = parts[1]330 if len(parts) > 2:331 entry["postal"] = parts[2]332 priors.append(entry)333 if priors:334 identity["prior_addresses"] = priors335 cfg = config_mod.load_config()336 consent = {"authorized": bool(args.consent), "method": args.consent_method, "recorded_at": dossier_mod.now()}337 residency = args.residency or "US"338 prefs = {339 "email_mode": args.email_mode or cfg["email_mode"],340 "rescan_interval_days": cfg["default_rescan_interval_days"],341 }342 if args.contact_email:343 prefs["contact_email_for_optouts"] = args.contact_email344 d = dossier_mod.create(identity, consent, residency, prefs)345 _out({"subject_id": d["subject_id"], "authorized": dossier_mod.is_authorized(d),346 "residency": residency, "email_mode": (prefs or {}).get("email_mode"),347 "names": dossier_mod.all_names(d),348 "emails": len(d["identity"].get("emails") or []),349 "phones": len(d["identity"].get("phones") or []),350 "addresses": len(dossier_mod.all_addresses(d))})351 352 353def cmd_brokers(args) -> None:354 bl = brokers_mod.by_priority(*(args.priority or [])) if args.priority else brokers_mod.load_all()355 _out([356 {"id": b.get("id"), "name": b.get("name"), "priority": b.get("priority"),357 "method": (b.get("optout") or {}).get("method"), "owns": b.get("owns") or [],358 "source": b.get("source"), "confidence": b.get("confidence", "curated")}359 for b in bl360 ])361 362 363def cmd_refresh_brokers(args) -> None:364 res = badbool.refresh(paths_mod.brokers_cache_path())365 curated_ids = {b["id"] for b in brokers_mod._load_curated()}366 new = [b["id"] for b in brokers_mod.load_live_cache() if b["id"] not in curated_ids]367 out = {**res, "curated": len(curated_ids), "new_from_live": len(new),368 "people_search_total": len(brokers_mod.load_all()),369 "note": "Live records have confidence=auto; verify their opt-out URL before acting."}370 if not getattr(args, "no_registry", False):371 try:372 reg = registry.refresh_all(paths_mod.registry_cache_path())373 out["registry"] = {"total": reg["total"], "sources": reg["sources"],374 "portals": reg["portals"],375 "note": "Coverage lane worked via the CA DROP one-shot + CCPA email, "376 "not the people-search scan. VT/OR/TX are search portals (no "377 "bulk export); CA is the superset. See `drop` and `registry`."}378 except Exception as exc: # noqa: BLE001 - registry pull is best-effort379 out["registry_error"] = str(exc)380 _out(out)381 382 383def cmd_registry(args) -> None:384 recs = brokers_mod.load_registry_cache()385 if not recs:386 _out({"registered_brokers": 0,387 "note": "registry empty - run `refresh-brokers` (pulls the CA Data Broker Registry)"})388 return389 fcra = sum(1 for r in recs if (r.get("optout") or {}).get("fcra"))390 out = {"registered_brokers": len(recs), "fcra_regulated": fcra,391 "source": "CA Data Broker Registry (CPPA, 2025)", "drop_url": registry.DROP_URL,392 "other_state_portals": registry.portals()}393 if args.search:394 q = args.search.lower()395 hits = [r for r in recs if q in (r.get("name") or "").lower()396 or q in (r.get("id") or "") or q in ((r.get("optout") or {}).get("email") or "").lower()]397 out["matches"] = [{"id": r["id"], "name": r["name"],398 "email": (r.get("optout") or {}).get("email"),399 "url": (r.get("optout") or {}).get("url"),400 "fcra": (r.get("optout") or {}).get("fcra")} for r in hits[:args.limit]]401 out["match_count"] = len(hits)402 _out(out)403 404 405def cmd_drop(args) -> None:406 """The one-shot legal lever: CA DROP deletes from ALL registered brokers at once."""407 d = _require_subject(args.subject)408 dossier_mod.require_authorized(d)409 reg = brokers_mod.load_registry_cache()410 res = (d.get("residency_jurisdiction") or "US").upper()411 eligible = res.startswith("US-CA")412 if args.filed:413 prefs = d.setdefault("preferences", {})414 prefs["drop_filed_at"] = dossier_mod.now()415 dossier_mod.save(d)416 _out({"subject": args.subject, "drop_filed_at": prefs["drop_filed_at"],417 "note": "recorded; `next` will stop surfacing the DROP one-shot"})418 return419 _out({420 "subject": args.subject,421 "eligible": eligible,422 "residency": res,423 "drop_url": registry.DROP_URL,424 "covers_registered_brokers": len(reg),425 "steps": ([426 "Go to privacy.ca.gov/drop and create/verify a DROP account (CA resident).",427 "Submit ONE deletion request; it applies to EVERY registered data broker "428 f"({len(reg)} in the current registry). Brokers must process starting 2026-08-01.",429 "After filing, run `drop <subject> --filed` so the loop stops re-surfacing it.",430 ] if eligible else [431 "DROP is a California mechanism; this subject's residency is not US-CA.",432 "Parity path for non-CA: work the people-search sites via `next`, and send targeted "433 "CCPA/GDPR deletion emails to registry brokers that hold this person's data "434 "(`registry --search`, then `send-email`).",435 ]),436 "note": "DROP is the highest-leverage removal: one request covers the whole registry.",437 })438 439 440def cmd_plan(args) -> None:441 d = _require_subject(args.subject)442 dossier_mod.require_authorized(d)443 cfg = config_mod.load_config()444 bl = brokers_mod.by_priority(*(args.priority or [])) if args.priority else brokers_mod.load_all()445 bcc = config_mod.browser_clears_captcha(cfg)446 if getattr(args, "batch", False):447 _out(tiers.batch_plan(d, bl, cfg, ledger_mod.load(args.subject), bcc))448 else:449 _out(tiers.plan(d, bl, cfg, bcc))450 451 452def cmd_fanout(args) -> None:453 d = _require_subject(args.subject)454 dossier_mod.require_authorized(d)455 bl = brokers_mod.by_priority(*(args.priority or [])) if args.priority else brokers_mod.load_all()456 grouping = tiers.fanout(bl, batch_size=args.size)457 mode = "scan AND opt-out (operator authorized submissions)" if args.optout \458 else "READ-ONLY scan (submit nothing; reconnaissance only)"459 batches = []460 for i, ids in enumerate(grouping["batches"], 1):461 brief = (462 f"You are scan worker {i} of {len(grouping['batches'])} for the `unbroker` skill. First "463 f"load the `unbroker` skill and read its references/methods.md. Use the `web` toolset "464 f"(web_search `site:` + web_extract), NOT `browser` (browser navigation is heavy and times "465 f"out). Subject id: {args.subject}. Handle ONLY these brokers: {', '.join(ids)}. "466 f"For EACH broker: read references/brokers/<id>.json; run EVERY search vector from "467 f"`pdd.py plan {args.subject}` (filtered to your brokers); build URLs from search.url_patterns "468 f"and heed url_format_quirks; a 404 is INCONCLUSIVE (rebuild/try the on-site search box), not "469 f"not_found. ECONOMY: at most ~3 web calls per broker; the moment a page shows antibot "470 f"(Cloudflare 'just a moment'/DataDome) or hangs, record `blocked` and move on -- do NOT "471 f"retry-loop. Confirm the SUBJECT vs namesakes/relatives by ADDRESS/DOB before recording "472 f"`found` (ignore SEO-templated page titles/intro that just echo the query -- require a real "473 f"result card; a public property/address record with no displayed personal NAME is "474 f"not_found, not found). Record each outcome via `pdd.py record {args.subject} <broker> "475 f"<found|not_found|indirect_exposure|blocked> --found <bool> --evidence '{{\"listing_urls\":[...]}}'`. "476 f"Mode: {mode}. Broker JSON files are READ-ONLY for you -- do NOT edit them; if you discover "477 f"a URL/quirk, put it in your report for the parent to fold in. Return a concise structured "478 f"per-broker report."479 )480 batches.append({"batch": i, "brokers": ids, "brief": brief})481 _out({482 "subject": args.subject,483 "broker_count": grouping["broker_count"],484 "batch_size": grouping["batch_size"],485 "should_fanout": grouping["should_fanout"],486 "batch_count": len(batches),487 "batches": batches,488 "instruction": (489 "If should_fanout is true you MUST spawn ONE delegate_task subagent per batch IN PARALLEL, "490 "passing each batch's `brief`; do not scan all brokers yourself sequentially. Wait for every "491 "report, consolidate, then proceed to opt-outs. If false, just scan the brokers inline."492 ),493 })494 495 496def cmd_record(args) -> None:497 d = _require_subject(args.subject)498 dossier_mod.require_authorized(d)499 broker = brokers_mod.get(args.broker)500 # Auto-stamp follow-up scheduling (next_recheck_at / removal_confirmed_at) so the501 # autonomous loop knows when to come back without anyone remembering to set it.502 fields = ledger_mod.followup_fields(args.state, broker, d)503 if args.found is not None:504 fields["found"] = args.found505 if args.evidence:506 fields["evidence"] = json.loads(args.evidence)507 if args.reason:508 fields["human_task_reason"] = args.reason509 case = ledger_mod.transition(args.subject, args.broker, args.state, **fields)510 if args.disclosed:511 ledger_mod.log_disclosure(args.subject, args.broker, args.disclosed, args.channel or "unknown")512 _out({"broker": args.broker, "state": case["state"],513 "next_recheck_at": case.get("next_recheck_at")})514 515 516def _email_request(d: dict, b: dict, kind: str, listings, identifiers) -> tuple[dict, list[str]]:517 """Least-disclosure (fields, disclosed_names) for an opt-out/legal email of KIND.518 519 A removal letter must self-identify. Name + a contact email are already known to the520 broker (the name is displayed on the very listing being removed), so not extra exposure.521 """522 fields = dossier_mod.select_disclosure(d, (b.get("optout") or {}).get("inputs", []))523 ident = d.get("identity", {})524 if ident.get("full_name"):525 fields.setdefault("full_name", ident["full_name"])526 fields.setdefault("contact_email", dossier_mod.contact_email(d) or "")527 if listings:528 fields["listing_urls"] = listings529 if kind == "ccpa_indirect":530 # Indirect exposure: name ONLY the subject's own identifiers to scrub from a third party's531 # record. Default to the contact email + the subject's name-as-relative if none specified.532 # The indirect template renders ONLY these placeholders; do not over-report disclosure with533 # unrelated dossier fields (phone/street/postal) that select_disclosure happened to populate.534 ids = list(identifiers or [])535 if not ids:536 ids = [contact for contact in [dossier_mod.contact_email(d)] if contact]537 ids.append(f'the name "{ident.get("full_name")}" where it appears as a relative/associated person')538 fields = {539 "full_name": fields.get("full_name"),540 "contact_email": fields.get("contact_email"),541 "listing_urls": fields.get("listing_urls"),542 "my_identifiers": ids,543 }544 return fields, ["contact_email", "full_name", "my_identifiers"]545 return fields, sorted(fields.keys())546 547 548def cmd_render_email(args) -> None:549 d = _require_subject(args.subject)550 dossier_mod.require_authorized(d)551 b = brokers_mod.get(args.broker)552 if not b:553 sys.exit(f"error: unknown broker {args.broker!r}")554 kind = getattr(args, "kind", "generic") or "generic"555 fields, disclosed = _email_request(d, b, kind, args.listing, getattr(args, "identifier", None))556 if kind == "generic":557 draft = email_modes.render_draft(b, fields)558 else:559 draft = email_modes.render_request_draft(b, fields, kind=kind)560 ledger_mod.log_disclosure(args.subject, args.broker, list(disclosed), f"email_draft:{kind}")561 _out({"draft": str(draft), "kind": kind, "disclosed_fields": disclosed})562 563 564def cmd_send_email(args) -> None:565 """Mode B: render AND deliver the opt-out/legal request - no human in the loop.566 567 Sends ONLY to an address the broker record itself declares (emailer enforces it),568 then records the ledger transition + disclosure and auto-stamps the recheck date.569 """570 d = _require_subject(args.subject)571 dossier_mod.require_authorized(d)572 b = brokers_mod.get(args.broker)573 if not b:574 sys.exit(f"error: unknown broker {args.broker!r}")575 cfg = config_mod.load_config()576 mode = cfg.get("email_mode")577 if mode not in ("programmatic", "alias", "browser"):578 sys.exit("error: email_mode is draft_only; run `setup --email-mode browser` (no password; "579 "sends via your logged-in webmail) or `--email-mode programmatic`, or use "580 "`render-email` and send it yourself")581 if not args.listing:582 sys.exit("error: --listing <confirmed-url> is required (verify-before-disclose: never "583 "email a broker about an unconfirmed listing)")584 # Idempotency: don't re-send if this case is already submitted/beyond (prevents duplicate585 # requests when an action is retried). --force overrides.586 _POST_SUBMIT = {"submitted", "verification_pending", "awaiting_processing", "confirmed_removed"}587 current = ledger_mod.get_case(args.subject, args.broker).get("state")588 if current in _POST_SUBMIT and not getattr(args, "force", False):589 _out({"skipped": True, "broker": args.broker, "state": current,590 "note": "already submitted; not re-sending (idempotent). Use --force to re-send."})591 return592 kind = getattr(args, "kind", "generic") or "generic"593 fields, disclosed = _email_request(d, b, kind, args.listing, getattr(args, "identifier", None))594 body = legal.render_optout_email(b, fields) if kind == "generic" else legal.render_request(kind, b, fields)595 596 if mode == "browser":597 # No network / no credentials: hand the agent a recipient-locked payload to send in the598 # operator's webmail via browser_* tools. State still records deterministically here.599 payload = emailer.browser_send_payload(b, body, to=args.to)600 ledger_mod.log_disclosure(args.subject, args.broker, list(disclosed), f"email_browser:{kind}")601 case = ledger_mod.transition(args.subject, args.broker, "submitted",602 **ledger_mod.followup_fields("submitted", b, d))603 _out({"send_via": "browser", "compose": payload, "kind": kind, "disclosed_fields": disclosed,604 "state": case["state"], "next_recheck_at": case.get("next_recheck_at"),605 "instruction": "In the operator's logged-in webmail, compose a NEW email to compose.to "606 "with compose.subject/body EXACTLY (disclose nothing beyond it) and send "607 "it via browser_* tools. Then use `verify-link` on any confirmation reply.",608 "note": "recipient is locked to the broker's declared address"})609 return610 611 result = emailer.send(b, body, to=args.to,612 min_interval=float(cfg.get("email_min_interval_seconds", 0) or 0))613 ledger_mod.log_disclosure(args.subject, args.broker, list(disclosed), f"email_sent:{kind}")614 case = ledger_mod.transition(args.subject, args.broker, "submitted",615 **ledger_mod.followup_fields("submitted", b, d))616 _out({"sent": result, "send_via": "smtp", "kind": kind, "disclosed_fields": disclosed,617 "state": case["state"], "next_recheck_at": case.get("next_recheck_at"),618 "note": "if this broker verifies by email, `poll-verification` will pick up the link"})619 620 621def cmd_verify_link(args) -> None:622 """Extract a broker's verification link from email text the agent read in webmail (browser mode).623 624 IMAP-free counterpart to `poll-verification`: the agent opens the broker's confirmation email625 in the operator's webmail, pastes the body here, and gets the anti-phishing-scored link back.626 """627 _require_subject(args.subject)628 b = brokers_mod.get(args.broker)629 if not b:630 sys.exit(f"error: unknown broker {args.broker!r}")631 text = args.text632 if args.file:633 text = Path(args.file).read_text(encoding="utf-8", errors="replace")634 if not text:635 sys.exit("error: provide --text '<email body>' (or --file) from the broker's confirmation email")636 link = email_modes.extract_verification_link(text, b)637 _out({"broker": args.broker, "verification_link": link,638 "next": ("browser_navigate the link IN THE SAME browser (sessions are browser-bound), "639 f"complete the flow, then `record {args.subject} {args.broker} awaiting_processing`"640 if link else641 "no broker/opt-out-scoped link found in that text; confirm you opened the right email")})642 643 644def cmd_poll_verification(args) -> None:645 """Poll the inbox for brokers' verification links (Mode B) - replaces the human click-chase.646 647 For each in-flight case (submitted / verification_pending with email_verification),648 extract the broker's link (anti-phishing scored). A found link auto-advances649 submitted -> verification_pending (the email HAS arrived); the agent must then OPEN650 the link in its own browser (sessions are browser-bound) and record the next state.651 """652 d = _require_subject(args.subject)653 dossier_mod.require_authorized(d)654 led = ledger_mod.load(args.subject)655 targets = []656 for bid, case in sorted(led.items()):657 if args.broker and bid != args.broker:658 continue659 if case.get("state") not in ("submitted", "verification_pending"):660 continue661 b = brokers_mod.get(bid)662 if b and (((b.get("optout") or {}).get("requires")) or {}).get("email_verification"):663 targets.append((bid, case, b))664 if not targets:665 _out({"subject": args.subject, "results": [],666 "note": "no in-flight cases awaiting email verification"})667 return668 results = []669 for bid, case, b in targets:670 hit = emailer.find_verification_link(b, since_days=args.since_days)671 if hit:672 if case.get("state") == "submitted":673 ledger_mod.transition(args.subject, bid, "verification_pending",674 **ledger_mod.followup_fields("verification_pending", b, d))675 results.append({"broker": bid, "verification_link": hit["link"],676 "email_from": hit.get("from"), "email_subject": hit.get("subject"),677 "next": f"browser_navigate the link IN THE AGENT'S OWN BROWSER, complete "678 f"the flow, then `record {args.subject} {bid} awaiting_processing` "679 f"(or confirmed_removed only after a verifying re-scan)"})680 else:681 results.append({"broker": bid, "verification_link": None,682 "next": "no matching email yet; poll again later (next_recheck_at is set)"})683 _out({"subject": args.subject, "results": results})684 685 686def cmd_next(args) -> None:687 d = _require_subject(args.subject)688 dossier_mod.require_authorized(d)689 cfg = config_mod.load_config()690 bl = brokers_mod.by_priority(*(args.priority or [])) if args.priority else brokers_mod.load_all()691 _out(autopilot.next_actions(d, bl, cfg, ledger_mod.load(args.subject)))692 693 694def cmd_tasks(args) -> None:695 _require_subject(args.subject)696 print(report_mod.human_tasks_markdown(args.subject))697 698 699def cmd_due(args) -> None:700 _require_subject(args.subject)701 cases = ledger_mod.due(args.subject)702 _out({"subject": args.subject, "due_count": len(cases),703 "cases": [{"broker_id": c.get("broker_id"), "state": c.get("state"),704 "next_recheck_at": c.get("next_recheck_at")} for c in cases],705 "note": "run `next` for the concrete follow-up action per case"})706 707 708def cmd_show(args) -> None:709 """Read a case's recorded state + evidence (so the parent can re-verify a subagent's `found`710 without re-deriving listing URLs)."""711 _require_subject(args.subject)712 case = ledger_mod.get_case(args.subject, args.broker)713 _out({"broker": args.broker, "state": case.get("state"), "found": case.get("found"),714 "evidence": case.get("evidence") or {},715 "disclosure_log": case.get("disclosure_log") or [],716 "next_recheck_at": case.get("next_recheck_at"),717 "human_task_reason": case.get("human_task_reason"),718 "history": case.get("history") or []})719 720 721def cmd_status(args) -> None:722 _require_subject(args.subject)723 print(report_mod.render_markdown(args.subject))724 725 726def cmd_report(args) -> None:727 _require_subject(args.subject)728 if args.sheets:729 _out(report_mod.sheets_rows(args.subject))730 else:731 print(report_mod.render_markdown(args.subject))732 733 734def build_parser() -> argparse.ArgumentParser:735 p = argparse.ArgumentParser(prog="pdd", description="unbroker helper CLI")736 sub = p.add_subparsers(dest="cmd", required=True)737 738 s = sub.add_parser("setup", help="write install config (easiest-first defaults; --auto = most autonomous)")739 s.add_argument("--auto", action="store_true",740 help="detect capabilities and pick the most autonomous valid config (no questions)")741 s.add_argument("--autonomy", dest="autonomy", choices=sorted(config_mod.VALID["autonomy"]))742 s.add_argument("--email-mode", dest="email_mode", choices=sorted(config_mod.VALID["email_mode"]))743 s.add_argument("--browser-backend", dest="browser_backend", choices=sorted(config_mod.VALID["browser_backend"]))744 s.add_argument("--tracker-backend", dest="tracker_backend", choices=sorted(config_mod.VALID["tracker_backend"]))745 s.add_argument("--encryption", dest="encryption", choices=sorted(config_mod.VALID["encryption"]))746 s.set_defaults(func=cmd_setup)747 748 s = sub.add_parser("doctor", help="readiness check: config, brokers, available upgrades")749 s.set_defaults(func=cmd_doctor)750 751 s = sub.add_parser("cdp",752 help="launch/detect the operator's Chrome over CDP (Phase-2 browser + webmail)")753 s.add_argument("--port", type=int, default=cdp.DEFAULT_PORT, help="remote debugging port (default 9222)")754 s.add_argument("--profile",755 help="user-data-dir (default: $HERMES_HOME/chrome-debug, a dedicated debug profile)")756 s.add_argument("--browser", help="path to (or PATH name of) a Chrome/Chromium/Brave/Edge binary")757 s.add_argument("--check", action="store_true",758 help="only report whether a debug browser is live; do not launch")759 s.add_argument("--print", dest="print_only", action="store_true",760 help="print the launch command instead of launching it (run it yourself)")761 s.set_defaults(func=cmd_cdp)762 763 s = sub.add_parser("intake", help="create a subject dossier (records consent)")764 s.add_argument("--json", help="path to a dossier JSON file (overrides flags)")765 s.add_argument("--full-name")766 s.add_argument("--alias", action="append", metavar="NAME",767 help="other name the subject is listed under (maiden/married/nickname); repeatable")768 s.add_argument("--email", action="append", metavar="EMAIL", help="repeatable")769 s.add_argument("--phone", action="append", metavar="PHONE", help="repeatable")770 s.add_argument("--street", help="current street line1 (enables reverse-address search)")771 s.add_argument("--city")772 s.add_argument("--state")773 s.add_argument("--postal")774 s.add_argument("--prior-location", dest="prior_location", action="append", metavar="City,ST",775 help="a past city/state (or City,ST,ZIP); repeatable")776 s.add_argument("--dob", help="date of birth YYYY-MM-DD (only used if a broker requires it)")777 s.add_argument("--contact-email", dest="contact_email",778 help="which email to use for opt-out correspondence (default: first)")779 s.add_argument("--residency", help="e.g. US, US-CA")780 s.add_argument("--consent", action="store_true", help="subject authorizes removal on their behalf")781 s.add_argument("--consent-method", default="self", choices=["self", "written_authorization", "poa"])782 s.add_argument("--email-mode", dest="email_mode", choices=sorted(config_mod.VALID["email_mode"]))783 s.set_defaults(func=cmd_intake)784 785 s = sub.add_parser("brokers", help="list the broker database (curated + live)")786 s.add_argument("--priority", action="append", choices=["crucial", "high", "standard", "long_tail"])787 s.set_defaults(func=cmd_brokers)788 789 s = sub.add_parser("refresh-brokers",790 help="pull the latest BADBOOL people-search list + the CA data broker registry")791 s.add_argument("--no-registry", dest="no_registry", action="store_true",792 help="skip the CA registry pull (BADBOOL people-search only)")793 s.set_defaults(func=cmd_refresh_brokers)794 795 s = sub.add_parser("registry",796 help="CA Data Broker Registry coverage (hundreds of brokers; DROP/email lane)")797 s.add_argument("--search", help="find registered brokers by name / id / email substring")798 s.add_argument("--limit", type=int, default=25, help="max matches to print (default 25)")799 s.set_defaults(func=cmd_registry)800 801 s = sub.add_parser("drop",802 help="CA DROP one-shot: delete from ALL registered brokers in one request")803 s.add_argument("subject")804 s.add_argument("--filed", action="store_true", help="mark DROP as filed (stops `next` surfacing it)")805 s.set_defaults(func=cmd_drop)806 807 s = sub.add_parser("plan", help="compute per-broker tier + next action for a subject")808 s.add_argument("subject")809 s.add_argument("--priority", action="append", choices=["crucial", "high", "standard", "long_tail"])810 s.add_argument("--batch", action="store_true",811 help="phase-oriented batch view: overlays ledger state, groups by next action "812 "(unscanned/found/indirect/blocked/in_progress/done), collapses ownership clusters")813 s.set_defaults(func=cmd_plan)814 815 s = sub.add_parser("fanout", help="batch brokers into parallel delegate_task subagents (large runs)")816 s.add_argument("subject")817 s.add_argument("--priority", action="append", choices=["crucial", "high", "standard", "long_tail"])818 s.add_argument("--size", type=int, default=5, help="brokers per subagent batch (default 5; 8+ times out)")819 s.add_argument("--optout", action="store_true",820 help="brief authorizes opt-out submission (default: read-only scan)")821 s.set_defaults(func=cmd_fanout)822 823 s = sub.add_parser("record", help="record a ledger state transition after an agent action")824 s.add_argument("subject")825 s.add_argument("broker")826 s.add_argument("state", choices=ledger_mod.STATES)827 s.add_argument("--found", type=lambda v: v.strip().lower() in ("1", "true", "yes", "y"))828 s.add_argument("--evidence", help="JSON object stored as case.evidence")829 s.add_argument("--disclosed", action="append", metavar="FIELD", help="field name disclosed")830 s.add_argument("--channel", help="disclosure channel, e.g. web_form / email")831 s.add_argument("--reason", help="for human_task_queued: why a human is needed (shown in `tasks`)")832 s.set_defaults(func=cmd_record)833 834 s = sub.add_parser("next", help="autonomous action queue: exactly what to do right now")835 s.add_argument("subject")836 s.add_argument("--priority", action="append", choices=["crucial", "high", "standard", "long_tail"])837 s.set_defaults(func=cmd_next)838 839 s = sub.add_parser("send-email", help="Mode B: render AND send the opt-out/legal request (records it)")840 s.add_argument("subject")841 s.add_argument("broker")842 s.add_argument("--listing", action="append", metavar="URL", required=False,843 help="confirmed listing URL (required: verify-before-disclose)")844 s.add_argument("--kind", choices=["generic", "ccpa", "ccpa_agent", "ccpa_indirect", "gdpr"],845 default="generic")846 s.add_argument("--identifier", action="append", metavar="ID",847 help="(ccpa_indirect only) a specific own-identifier to remove; repeatable")848 s.add_argument("--to", help="override recipient (must be an address the broker record declares)")849 s.add_argument("--force", action="store_true", help="re-send even if already submitted (default: idempotent skip)")850 s.set_defaults(func=cmd_send_email)851 852 s = sub.add_parser("poll-verification",853 help="Mode B (IMAP): poll the inbox for brokers' verification links (anti-phishing scored)")854 s.add_argument("subject")855 s.add_argument("--broker", help="only this broker (default: every in-flight verification case)")856 s.add_argument("--since-days", dest="since_days", type=int, default=3)857 s.set_defaults(func=cmd_poll_verification)858 859 s = sub.add_parser("verify-link",860 help="browser mode: extract a broker's verification link from pasted webmail text")861 s.add_argument("subject")862 s.add_argument("broker")863 s.add_argument("--text", help="the confirmation email body (read from the operator's webmail)")864 s.add_argument("--file", help="path to a file with the email body (alternative to --text)")865 s.set_defaults(func=cmd_verify_link)866 867 s = sub.add_parser("tasks", help="ONE consolidated human-task digest (present at end of run)")868 s.add_argument("subject")869 s.set_defaults(func=cmd_tasks)870 871 s = sub.add_parser("show", help="read a case's state + evidence (for parent re-verification)")872 s.add_argument("subject")873 s.add_argument("broker")874 s.set_defaults(func=cmd_show)875 876 s = sub.add_parser("due", help="cases whose recheck window has arrived (cron re-scan queue)")877 s.add_argument("subject")878 s.set_defaults(func=cmd_due)879 880 s = sub.add_parser("render-email", help="render a Mode-A opt-out / legal-request draft (least-disclosure)")881 s.add_argument("subject")882 s.add_argument("broker")883 s.add_argument("--listing", action="append", metavar="URL", help="confirmed listing URL")884 s.add_argument("--kind", choices=["generic", "ccpa", "ccpa_agent", "ccpa_indirect", "gdpr"],885 default="generic",886 help="request type. 'ccpa_indirect' = delete MY identifiers from a third party's "887 "record (indirect exposure); default 'generic' opt-out.")888 s.add_argument("--identifier", action="append", metavar="ID",889 help="(ccpa_indirect only) a specific own-identifier to request removal of "890 "(e.g. an email or phone). Repeatable. Defaults to the contact email + "891 "name-as-relative if omitted.")892 s.set_defaults(func=cmd_render_email)893 894 s = sub.add_parser("status", help="print a Markdown status report")895 s.add_argument("subject")896 s.set_defaults(func=cmd_status)897 898 s = sub.add_parser("report", help="status report (default) or --sheets rows")899 s.add_argument("subject")900 s.add_argument("--sheets", action="store_true", help="emit Google Sheets rows as JSON")901 s.set_defaults(func=cmd_report)902 return p903 904 905def main(argv=None) -> None:906 args = build_parser().parse_args(argv)907 try:908 args.func(args)909 except (PermissionError, ValueError, RuntimeError, FileNotFoundError) as exc:910 sys.exit(f"error: {exc}")911 912 913if __name__ == "__main__":914 main()915 Referenced from SKILL.md
SKILL.mdView in source ↗
Source excerpt starting at line 27.SKILL.mdView in source ↗27The Python CLI (`scripts/pdd.py`) owns the deterministic state - config, dossiers + consent, the28broker database, tier planning, the ledger, drafts, reports, **email sending (SMTP), verification-link
Source excerpt starting at line 100.100```bash101PDD="python scripts/pdd.py"102```