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).
138 lines
4.3 KiB
Python
138 lines
4.3 KiB
Python
"""Storage helpers (stdlib only): atomic JSON, append-only JSONL, strict perms.
|
|
|
|
Default backend is local-json. The optional google-sheets tracker is handled in
|
|
report.py by emitting rows for the `google-workspace` skill; this module stays
|
|
dependency-free so the hermetic tests never touch the network.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import crypto
|
|
import paths
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def locked(target: Path, timeout: float = 10.0, stale: float = 30.0):
|
|
"""Portable advisory lock via an O_EXCL lockfile next to `target`.
|
|
|
|
Serializes read-modify-write on shared JSON (the ledger) across concurrent
|
|
processes - a cron re-scan overlapping a manual run, or multiple tenants -
|
|
so one writer can't clobber another's update. A lock older than `stale`
|
|
seconds is treated as abandoned (crashed writer) and broken, so a dead
|
|
process can never deadlock the queue. Works on macOS/Linux/Windows (O_EXCL).
|
|
"""
|
|
ensure_dir(target.parent)
|
|
lock = target.with_name(target.name + ".lock")
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
try:
|
|
fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
try:
|
|
os.write(fd, str(os.getpid()).encode())
|
|
finally:
|
|
os.close(fd)
|
|
break
|
|
except FileExistsError:
|
|
try:
|
|
if time.time() - lock.stat().st_mtime > stale:
|
|
lock.unlink(missing_ok=True)
|
|
continue
|
|
except OSError:
|
|
pass
|
|
if time.monotonic() >= deadline:
|
|
raise TimeoutError(f"could not acquire lock {lock} within {timeout}s")
|
|
time.sleep(0.05)
|
|
try:
|
|
yield
|
|
finally:
|
|
with contextlib.suppress(OSError):
|
|
lock.unlink(missing_ok=True)
|
|
|
|
|
|
def _secure(path: Path, mode: int) -> None:
|
|
try:
|
|
os.chmod(path, mode)
|
|
except OSError:
|
|
pass # non-POSIX / unsupported FS; HERMES_HOME directory perms still apply
|
|
|
|
|
|
def ensure_dir(path: Path) -> Path:
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
_secure(path, 0o700)
|
|
return path
|
|
|
|
|
|
def _is_sensitive(path: Path) -> bool:
|
|
"""Per-subject docs (dossier, ledger) are sensitive; config/cache are not."""
|
|
try:
|
|
Path(path).resolve().relative_to(paths.subjects_dir().resolve())
|
|
return True
|
|
except (ValueError, OSError):
|
|
return False
|
|
|
|
|
|
def _age_path(path: Path) -> Path:
|
|
return path.with_name(path.name + ".age")
|
|
|
|
|
|
def _atomic_write(path: Path, data: bytes) -> Path:
|
|
tmp = path.with_name(path.name + ".tmp")
|
|
tmp.write_bytes(data)
|
|
_secure(tmp, 0o600)
|
|
os.replace(tmp, path)
|
|
_secure(path, 0o600)
|
|
return path
|
|
|
|
|
|
def write_json(path: Path, obj: Any) -> Path:
|
|
ensure_dir(path.parent)
|
|
data = (json.dumps(obj, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
|
if _is_sensitive(path) and crypto.encryption_setting() == "age":
|
|
if not crypto.age_available():
|
|
raise RuntimeError(
|
|
"encryption=age is configured but `age` is not available; "
|
|
"refusing to write PII as plaintext. Install age or run `setup --encryption none`."
|
|
)
|
|
target = _atomic_write(_age_path(path), crypto.encrypt(data))
|
|
if path.exists():
|
|
path.unlink() # migrate plaintext -> ciphertext
|
|
return target
|
|
target = _atomic_write(path, data)
|
|
ap = _age_path(path)
|
|
if ap.exists():
|
|
ap.unlink() # encryption turned off -> drop stale ciphertext
|
|
return target
|
|
|
|
|
|
def read_json(path: Path, default: Any = None) -> Any:
|
|
ap = _age_path(path)
|
|
if ap.exists():
|
|
return json.loads(crypto.decrypt(ap.read_bytes()).decode("utf-8"))
|
|
if path.exists():
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
return default
|
|
|
|
|
|
def append_jsonl(path: Path, record: dict) -> Path:
|
|
ensure_dir(path.parent)
|
|
with path.open("a", encoding="utf-8") as fh:
|
|
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
_secure(path, 0o600)
|
|
return path
|
|
|
|
|
|
def read_jsonl(path: Path) -> list[dict]:
|
|
if not path.exists():
|
|
return []
|
|
out: list[dict] = []
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if line:
|
|
out.append(json.loads(line))
|
|
return out
|