Skip to content

Redaction and retention

Session logs contain real prompts and possibly sensitive data. Hansard treats redaction and retention as design decisions, not afterthoughts — both are on by default, and both are deliberately conservative.

Redaction: on by default, high-confidence secrets only

Every session is scrubbed for high-confidence secret shapes before any of it touches disk:

  • API keys (sk-…, ghp_…, github_pat_…, AKIA…/ASIA…)
  • Authorization: Bearer … tokens
  • PEM private key blocks (RSA/EC/DSA/OpenSSH/PKCS#8)
  • password=/api_key=/secret=-style assignments — and their structured-value counterpart, a dict key named password, apikey, secret, etc.

Applied to every place caller data enters the log: message text, agent.action.args, agent.action_result.output, turn.output() text, session.note() text, session.correct(value=...), and the caller-supplied part of session(meta=...) (the SDK's own agent/app/host/pid/ hansard_version fields are never candidates). Recursed through nested dicts/lists/tuples up to a depth of 20, to protect the never-block-the-caller guarantee against a pathological, self-nested structure.

Token shape, not just length

A shape-only regex (right length, right character class) also matches ordinary English -- "Bearer authentication" and "secret: nobody" pass the same length/charset test a real bearer token or password does. Raising the length threshold doesn't fix this: "authentication" is 14 characters, longer than plenty of real short tokens. The property that actually distinguishes a credential from a word is that credentials are almost always alphanumeric with at least one digit, and ordinary English words almost never are. So the bearer_token/password/api_key/secret patterns additionally require the matched value to contain a digit before treating it as a match. This is one cheap, well-justified heuristic — not a perfect one; an all-alphabetic secret (unusual in practice) is missed, which is the direction this module is built to fail in. See hansard.redaction's benign-prose corpus (tests/test_redaction.py) for the sentences this is checked against.

Every redaction records what kind of thing was removed, in the existing redacted field (["api_key"]) — the transcript shows that something was scrubbed without showing what. user_id is never redacted: identity is the one thing the whole product depends on being exact.

Deliberately not redacted by default

Emails, names, URLs, and IP addresses. Those are frequently the actual content of a multiplayer agent session — a transcript full of [REDACTED] is worth much less than one that reads. Available as opt-in categories:

hansard.Redactor(optional={"email", "url", "ip_address"})

Personal names are not offered as a category at all, even opt-in: unlike the shapes above, a name has no reliable regex signature — it is indistinguishable from a project name, a place, or a tool name without an NER model, which this project's zero-runtime-dependency, stdlib-only constraint rules out. A pattern that mostly misses real names while flagging ordinary nouns would be worse than not offering one at all — see Design decisions on why over-matching is treated as strictly worse than under-matching here.

The irreversibility problem

Redaction runs before anything touches disk, which is the point — but it means a bug in the redactor destroys evidence permanently, with no way to recover it. Every pattern in the default set is written to bias toward missing an unusual-looking secret rather than eating an ordinary one: under-matching is recoverable in spirit (nothing here claims to be a complete secret scanner), over-matching is not recoverable at all.

To disable redaction entirely — for callers with their own upstream scrubbing, or who genuinely need verbatim capture:

hansard.session(path="./sessions", agent="my-agent", redact=None)

prune: dry-run by default

hansard prune <dir> --older-than 30d [--yes]

Deleting audit logs is the most destructive thing this tool can do, and the failure mode of an accidental prune is unrecoverable evidence loss. So:

  • Dry-run by default. prune prints what it would delete without touching anything. --yes is required to actually delete. Exit code is 0 if every session in the target directory could be classified, 1 if any session was unreadable (see below) and therefore left out of the classification — dry-run or not, an unreadable session is something a human should be told about, not silently absent from the report.
  • Age comes from the session's own recorded timestamps — the last event's, specifically — never filesystem mtime. A restored backup has fresh mtimes and stale contents; using mtime would silently keep the wrong sessions. Using the session's first event instead of its last would be its own bug: a session that started three months ago but received a correction yesterday is not stale evidence, it's one someone is still actively working with.
  • Fails closed on unreadable input. A directory prune cannot positively identify as a Hansard session (no .jsonl segments, or ones that don't parse) is never a deletion candidate. A directory prune can identify but cannot fully read — corrupt, truncated, no parseable events to derive an age from — is reported as unreadable and also left alone: that unreadable state may be exactly the evidence someone needs to diagnose why it's corrupt, and pruning it would destroy the one copy.

See CLI reference for real captured prune output, both the "nothing eligible" and "would delete" cases.