tankslab.com/recall/source

bin/friday_signal.py

264 lines, exactly as they ship in the download.

#!/usr/bin/env python3 """ friday_signal.py - SIGNAL CODE for Recall. How we learn WHAT broke without learning WHO you are. Named friday_signal, NOT signal: hook.py puts bin/ on sys.path, so a module called signal.py here would shadow Python's stdlib `signal` for the whole process - and the stop path needs the real signal.SIGKILL. A leftover bin/signal.py made the save fail intermittently; the rename removes the trap for good. Ported from the Friday Ledger / SentinelLedger design (desktop since July) and its phone port, SignalCode.kt. Same promise, same shape, adapted from an Android screen to a command line. WHY A TOOL LIKE THIS NEEDS IT ----------------------------- Recall exists because a memory tool was shipping telemetry nobody consented to. The obvious overcorrection is to report nothing at all - and that is not privacy, it is blindness. A tool that dies silently on someone's machine and never tells anyone is not respecting its owner, it is failing them. "Can't fix what I don't know is broken" cuts both ways. THE SHAPE OF THE PROMISE | NOTHING IS EVER SENT. This mints a short code and writes a plain-text report to this machine. A person reads the exact text and decides what to do with it. | The standalone build has no network code at all, so it COULD not send if it wanted to. That is a fact a stranger can check with grep, not a promise. | What we keep: the command it happened in, the exception class, the message after redaction, and the machine picture (OS, python, sqlite versions). What we never touch: your name, your paths, your data, your account. | ONE file, overwritten. We are not building a log of a person's bad days - the LAST failure is what gets it fixed, and a history of failures is a history of when someone was working. | The text you read IS the text you would share. There is no second, richer copy. WHY IT SEVERS ON TOP OF redact.py --------------------------------- redact.py is built for CONTENT - tokens, keys, credentials in captured text. Exception strings leak differently: they carry filesystem paths with a person's name inside them (/home/dana/taxes/2024.db), and bare capitalised names with no cue word in front. Running only the content redactor here would look thorough and still ship the name. """ from __future__ import annotations import argparse import os import platform import re import secrets import sqlite3 import sys import traceback from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from redact import scrub # noqa: E402 # Crockford-ish: no 0/O, no 1/I/L, no U. A code read aloud down a bad line still lands. ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ" REPORT_FILE = "last_failure.txt" # Paths are the loudest leak in a traceback. The tail is what names a person. _PATHS = [ re.compile(r"/(?:home|Users)/[^/\s]+\S*"), re.compile(r"[A-Za-z]:\\Users\\[^\\\s]+\S*"), re.compile(r"/mnt/[a-z]/Users/[^/\s]+\S*"), re.compile(r"/(?:root|var/root)/\S*"), re.compile(r"(?:file|content)://\S+"), ] # Two or three capitalised words in a row. Only ever run over EXCEPTION TEXT, never # over anything the user wrote — which is what makes it safe to be this blunt. An # error message has no innocent use for "Dana Whitfield". _NAME_SEQ = re.compile(r"\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,2}\b") # Class names are the one legitimate source of capitals here; losing them would gut # the report's usefulness. _LOOKS_LIKE_CODE = re.compile(r"[.$_]|Error|Exception|Warning|SQL|None|True|False") def report_dir() -> Path: from paths import ensure_root return ensure_root() def mint() -> str: return "FAIL-" + "".join(secrets.choice(ALPHABET) for _ in range(5)) def reap(text: str) -> str: """Sever everything that could name a person. Content redactor first, then the error-string-specific rules.""" if not text or not text.strip(): return "" out = scrub(text)[0] for p in _PATHS: out = p.sub("[path removed]", out) out = _NAME_SEQ.sub( lambda m: m.group(0) if _LOOKS_LIKE_CODE.search(m.group(0)) else "[name removed]", out) return out.strip() def command_of(argv: list[str]) -> str: """The command a failure happened in, with anything variable taken out — 'recall search', never 'recall search "my client's tax id"'.""" if not argv: return "recall" name = Path(argv[0]).stem sub = argv[1] if len(argv) > 1 and not argv[1].startswith("-") else "" return f"{name} {sub}".strip()[:64] def build(err: BaseException, argv: list[str] | None = None) -> dict: return { "code": mint(), "command": command_of(argv if argv is not None else sys.argv), "error": type(err).__name__, "detail": reap(str(err)), # The last frame is where it broke; the rest of the traceback is mostly our # own paths, which is exactly what we do not ship. "where": _last_frame(err), "python": platform.python_version(), "sqlite": sqlite3.sqlite_version, "os": f"{platform.system()} {platform.release()}", } def _last_frame(err: BaseException) -> str: try: tb = traceback.extract_tb(err.__traceback__) if not tb: return "unknown" f = tb[-1] # file NAME only, never the path it sits in return f"{Path(f.filename).name}:{f.lineno} in {f.name}()" except Exception: return "unknown" def text(r: dict) -> str: """Exactly what the owner sees, and exactly what they would share. One text, no hidden second copy.""" return f"""{r['code']} What broke command {r['command']} error {r['error']} where {r['where']} detail {r['detail'] or '(no message)'} What it was running on os {r['os']} python {r['python']} sqlite {r['sqlite']} That is the whole report. It carries what broke and what you are running on. It does not carry your name, your paths, your data, or your account. NOTHING WAS SENT. You are reading this because you chose to look. """ def save(r: dict) -> Path: p = report_dir() / REPORT_FILE try: p.write_text(text(r), encoding="utf-8") except Exception: pass return p def install(argv: list[str] | None = None) -> None: """Catch what would otherwise be a silent death or a wall of traceback. The previous hook still runs afterwards - we are not swallowing the failure, not pretending the command worked, and not deciding on the owner's behalf that it is fine. We write the report, say the code, then get out of the way. """ previous = sys.excepthook def hook(kind, err, tb): try: r = build(err, argv) p = save(r) print(f"\n{r['code']} - something broke and Recall wrote a report.", file=sys.stderr) print(f" read it: recall signal ({p})", file=sys.stderr) print(" nothing was sent.\n", file=sys.stderr) except Exception: pass previous(kind, err, tb) sys.excepthook = hook def main() -> int: ap = argparse.ArgumentParser(prog="recall signal", description="show or clear the last failure report") ap.add_argument("--clear", action="store_true") ap.add_argument("--selftest", action="store_true") a = ap.parse_args() p = report_dir() / REPORT_FILE if a.selftest: return selftest() if a.clear: if p.exists(): p.unlink() print(f"cleared {p}") else: print("no report to clear") return 0 if not p.exists(): print("no failure recorded. Nothing has broken since the last clear.") return 0 print(p.read_text(encoding="utf-8")) print(f"[{p}]") return 0 def selftest() -> int: """Every severing rule must be PROVEN able to fire, or a report that looks clean is just a report nobody checked.""" problems: list[str] = [] cases = [ ("unix home path", "cannot open /home/dana/taxes/2024.db", "dana"), ("windows path", r"denied C:\Users\Dana\AppData\x.db", "Dana"), ("wsl path", "no file /mnt/c/Users/Dana/notes.md", "Dana"), ("person name", "owner Dana Whitfield not found", "Dana Whitfield"), ] for label, sample, leak in cases: out = reap(sample) if leak in out: problems.append(f"LEAKED {label}: {out!r} still contains {leak!r}") # must not gut the useful part keep = reap("sqlite3.OperationalError: no such column: origin") for token in ("OperationalError", "origin"): if token not in keep: problems.append(f"OVER-REDACTED: lost {token!r} from {keep!r}") # the shared text must be the SAME text that was shown - no richer second copy r = build(ValueError("boom"), ["recall", "search"]) if text(r) != text(r): problems.append("text() is not deterministic") if r["code"] not in text(r): problems.append("code missing from report body") # and it must carry a redacted secret, not the secret r2 = build(RuntimeError('auth failed for password = "hunter2swordfish"'), ["recall"]) if "hunter2swordfish" in text(r2): problems.append("SECRET LEAKED into the report") if problems: print("SIGNAL SELFTEST FAILED", file=sys.stderr) for x in problems: print(" " + x, file=sys.stderr) return 2 print(f"signal selftest OK - {len(cases)} severing rules proven able to fire") return 0 if __name__ == "__main__": sys.exit(main())