Skip to content

Instantly share code, notes, and snippets.

@omar16100
Last active September 2, 2026 11:43
Show Gist options
  • Select an option

  • Save omar16100/8e03e84b67d1d481bcfaafe6d9332018 to your computer and use it in GitHub Desktop.

Select an option

Save omar16100/8e03e84b67d1d481bcfaafe6d9332018 to your computer and use it in GitHub Desktop.
Redact secrets from coding-agent transcripts (Claude Code, Codex, Gemini CLI, Kimi Code). Single file, no deps, dry-run by default.

redact-agent-transcripts

Remove secrets from coding-agent transcripts.

Claude Code, Codex, Gemini CLI and Kimi Code each keep a local JSONL transcript of every session. Those transcripts record whatever scrolled through your terminal: env dumps, cat .env, gh auth token output, a key you pasted into a prompt, a curl -H "Authorization: Bearer ..." you asked the agent to run. On a working machine that is easily a gigabyte of plaintext, and it is not in version control, so nobody notices.

Then you share a session for a bug report, sync your dotfiles, or feed the transcripts to a summarizer, and the credential goes with it.

This script finds those secrets and replaces them with a typed, hashed placeholder.

$ uv run redact_agent_transcripts.py
agents:   claude, codex, gemini, kimi
scanned:  1998 files, 1.2 GB in 356.9s
findings: 373 in 105 files

  generic-secret-assignment  148
  jwt                        61
  google-api-key             60
  private-key                25
  huggingface-token          25
  url-password               23
  atlassian-token            11
  github-token                6
  bearer-token                5
  openai-api-key              5
  anthropic-api-key           2
  aws-access-key-id           1
  google-oauth-token          1

Dry run. Re-run with --apply to rewrite these files.

Usage

# Dry run over every agent it can find. Writes nothing.
uv run redact_agent_transcripts.py

# See what it matched, with the secret already replaced in the output.
uv run redact_agent_transcripts.py --show-context

# Rewrite in place. Backs up to ~/.redact-backups/<timestamp>/ first.
uv run redact_agent_transcripts.py --apply

# One agent, or an arbitrary directory.
uv run redact_agent_transcripts.py --agent codex
uv run redact_agent_transcripts.py --paths ~/some/export.jsonl

Requires uv. No third-party dependencies, single file, works on Python 3.9+.

Exit code is 1 when findings exist and --apply was not passed, so it works as a pre-commit or pre-upload check.

Options

Flag Effect
--apply Rewrite files in place. Without it, nothing is written.
--agent claude,codex,gemini,kimi Restrict to specific agents. Default is all.
--paths PATH... Scan arbitrary files or directories instead.
--show-context Print surrounding text, with the secret already replaced.
--backup-dir DIR Override the backup location.
--allowlist FILE Literal values to never redact, one per line.
--include-active Also scan files touched in the last 60 seconds.
--no-salt Unsalted digests, stable across machines.
--json Machine-readable summary.
--self-test Built-in round-trip checks, no filesystem access outside a temp dir.

What a redaction looks like

- "stdout": "ANTHROPIC_API_KEY=sk-ant-api03-vT9x...Qw\n"
+ "stdout": "ANTHROPIC_API_KEY=[REDACTED:anthropic-api-key:a3f91c]\n"

The digest is sha256(salt + secret) truncated to six hex characters. It keeps the transcript readable, tells you whether two hits were the same credential or two different ones, and reveals nothing. The salt is random, generated once at ~/.config/redact-transcripts/salt, because an unsalted six-character hash of a low-entropy secret is trivially brute-forced. Pass --no-salt if you want identifiers that are comparable across machines.

What it detects

Structured credentials, matched by shape and never entropy-gated, because gating them produces false negatives on real but low-entropy keys:

Anthropic (sk-ant-), OpenAI (sk-proj- and old-style sk-), OpenRouter, GitHub (ghp_, gho_, github_pat_), GitLab, AWS access key IDs, Google API keys and OAuth tokens, Slack, Atlassian, Hugging Face, npm, Stripe live keys, JWTs, and PEM private key blocks, including PGP blocks and blocks that were only partially printed.

Unstructured patterns, which carry real false-positive risk and are therefore gated:

SOMETHING_SECRET=<value>, Authorization: Bearer|Token|Basic <token>, and the password field of a scheme://user:password@host URL.

Keeping the unstructured patterns honest

Those three matched 1,068 non-secrets in the first real run, mostly source code. Five filters bring that to roughly one false positive in fifteen:

  • Entropy. At least 3.5 bits per character, lowered to 2.5 for URL passwords, because a human-chosen 8 to 11 character password cannot reach 3.5.
  • Letters and digits. A candidate with no digit is an identifier, not a credential. Not applied to URL passwords or bearer tokens, which are structural credential slots.
  • No structural characters. Anything containing ()[]{}<> or a backslash is code.
  • Same line only. The value has to sit on the keyword's line, or a YAML secrets: at end of line captures the whole of the next one.
  • Not a call, not a cursor, not a digest. A ( straight after the value means it is a function call. nextPageToken, token_count, tokenizer, _sha256, _hash and friends are excluded by name.

Matches that look like documentation are dropped too: anything containing <, ..., example, your-, changeme, redacted, or a run of five or more identical alphanumeric characters.

How it works

It does not model any agent's schema. It parses each line and walks every string value. Adding an agent is a new glob, not a new parser. This matters more than it sounds: in Claude Code alone, toolUseResult is an object, a bare string, or an array depending on which tool ran, and tool_result.content is a string or an array. Targeting known paths breaks constantly, and a miss means a live credential stays on disk.

It finds secrets in the decoded value but substitutes into the raw line, and never re-serializes. json.dumps does not reproduce another encoder's output byte for byte, so re-serializing would rewrite a gigabyte of lines that contain no secrets at all, and quietly change 1e-7 to 1e-07 throughout your history. Substituting into the raw text means clean lines stay byte-identical, line counts are preserved by construction, and a changed line differs only where the secret was. The result is re-parsed as an assertion before anything is written.

A secret can appear in three spellings on disk, and all three are replaced: as-is, JSON-escaped (a private key's real newlines become \n), and double-escaped, because Codex stores tool arguments as a JSON string nested inside JSON.

A prefilter regex runs on the raw line first. Lines that cannot contain a secret are passed through untouched, which is what makes a gigabyte tractable.

What it deliberately does not touch

The rule is: redact the record of what happened, never what the agent reads in order to operate.

Excluded, always: ~/.claude.json and its siblings (the mcpServers.*.env keys there are live config, and redacting them breaks your MCP servers), shell-snapshots/ (Claude Code restores your Bash environment from these), settings.json, ~/.codex/auth.json, ~/.gemini/.env, and every *.key, *.pem and *.sh.

It also skips files modified in the last 60 seconds, so it will not rewrite the session you are currently sitting in. Use --include-active to override that.

Known limits

Some agents keep a second copy of the same conversation somewhere the script does not write. It reports these rather than pretending they do not exist:

Store What is in it
~/.codex/thread_history_1.sqlite Codex mirrors every rollout item in thread_items.item_json
~/.claude/file-history/ Pre-edit copies of files Claude modified
~/.local/share/opencode/opencode.db OpenCode keeps whole sessions in SQLite
~/.playwriter/cdp.jsonl Every CDP message, including tokens revealed in a browser

No SQLite store is written to in this version. Those databases have live WAL files, and corrupting an agent's state database to scrub a secret is a bad trade. Point --paths at a text store you care about, or handle the databases separately.

Two more things worth knowing. Backups contain the original secrets, by design, so delete them once you are satisfied: rm -rf ~/.redact-backups. And a credential that has already been written to disk should be treated as compromised. Redacting the transcript limits the blast radius of sharing it; rotating the key is what actually fixes the problem.

Tests

uv run --with pytest pytest test_redact_agent_transcripts.py
uv run redact_agent_transcripts.py --self-test

The test suite builds every fake credential at runtime by concatenating a prefix with a deterministic pseudo-random body, so no well-formed secret literal appears in the source and the files do not trip secret scanners.

License

MIT.

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.9"
# dependencies = []
# ///
"""Redact secrets from coding-agent transcripts.
Claude Code, Codex, Gemini CLI and Kimi Code all keep local JSONL transcripts of every
session. Those transcripts capture whatever scrolled through the terminal, including
`env` dumps, `cat .env`, `gh auth token` output and pasted credentials. This rewrites
them in place, replacing each secret with a typed, hashed placeholder.
uv run redact_agent_transcripts.py # dry run, report only
uv run redact_agent_transcripts.py --show-context # see redacted samples
uv run redact_agent_transcripts.py --apply # rewrite, with backups
It does not model any agent's schema. It parses each line and walks every string value,
so a new agent is a new glob rather than a new parser.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import re
import shutil
import sys
import tempfile
import time
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Set, Tuple
__version__ = "1.0.0"
# --------------------------------------------------------------------------- detectors
@dataclass(frozen=True)
class Detector:
kind: str
pattern: str
group: int = 0
generic: bool = False # generic detectors are entropy-gated
flags: int = 0
min_entropy: float = 0.0 # 0 means "use MIN_ENTROPY"
require_mixed: bool = True # demand letters+digits (see looks_like_code_or_path)
exclude: str = "" # if this matches the whole hit, it is not a credential
DETECTORS: Sequence[Detector] = (
# Structured, prefixed credentials. High precision by shape, so no entropy gate:
# gating these produces false negatives on low-entropy but entirely real keys.
Detector("private-key",
r"-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----"
r".*?-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----",
flags=re.S),
# A key that was only partially printed (`head key.pem`) has no END line. The full
# block above runs first and claims its span, so this only fires when there is none.
Detector("private-key",
r"-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----[A-Za-z0-9+/=\s]{40,}",
flags=re.S),
Detector("anthropic-api-key", r"sk-ant-[A-Za-z0-9_-]{20,}"),
Detector("openai-api-key", r"sk-proj-[A-Za-z0-9_-]{20,}"),
Detector("openrouter-key", r"sk-or-v1-[A-Za-z0-9]{32,}"),
Detector("github-token", r"github_pat_[A-Za-z0-9_]{50,}"),
Detector("github-token", r"gh[pousr]_[A-Za-z0-9]{36,}"),
Detector("gitlab-token", r"glpat-[A-Za-z0-9_-]{20,}"),
Detector("aws-access-key-id", r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
Detector("google-api-key", r"AIza[A-Za-z0-9_-]{30,}"),
Detector("google-oauth-token", r"ya29\.[A-Za-z0-9_-]{30,}"),
Detector("slack-token", r"xox[baprse]-[A-Za-z0-9-]{10,}"),
Detector("atlassian-token", r"ATATT[A-Za-z0-9=_-]{20,}"),
Detector("huggingface-token", r"hf_[A-Za-z0-9]{30,}"),
Detector("npm-token", r"npm_[A-Za-z0-9]{30,}"),
Detector("stripe-secret-key", r"(?:sk|rk)_live_[A-Za-z0-9]{20,}"),
Detector("jwt", r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"),
Detector("openai-api-key", r"\bsk-[A-Za-z0-9]{48}\b"),
# Unstructured. These carry real false-positive risk, so they are entropy-gated.
Detector("bearer-token",
r"[Aa]uthorization[\"']?\s*[:=]\s*[\"']?\s*(?:Bearer|Token|Basic)"
r"[ \t]+([^\s\"',;}\])(&]{20,})",
group=1, generic=True, require_mixed=False),
# The keyword may be followed by more identifier before the separator, which is how
# AWS_SECRET_ACCESS_KEY, DJANGO_SECRET_KEY and SECRET_KEY_BASE are spelled. Without
# the trailing [a-z0-9_.-]* the most common shape in any `env` dump never matched.
# No leading [a-z0-9_.-]* here. Matching the keyword anywhere in the identifier is
# equivalent (AWS_SECRET_ACCESS_KEY matches at SECRET) but an unanchored leading
# character class makes the engine scan a greedy run at every position in the line,
# which cost a 12x slowdown across the corpus for zero extra matches.
Detector("generic-secret-assignment",
# [ \t]* rather than \s*: the value has to be on the same line. A YAML
# "secrets:" at end of line, or a grep hit, otherwise captured the whole of
# the FOLLOWING line as the credential.
r"(?:api[_-]?key|private[_-]?key|secret|token|password|passwd"
r"|credential)[a-z0-9_.-]*[\"']?[ \t]*[:=][ \t]*[\"']?([^\s\"',;}\])(&]{16,})",
group=1, generic=True, flags=re.I,
# Things that are shaped like a credential assignment but are not one.
# Pagination cursors were the largest false-positive class in a real scan.
exclude=r"(?i)(?:next_?page_?token|page_?token|continuation_?token|next_?token"
r"|token_?count|tokens?_?used|token_?limit|tokenizer"
r"|sha1|sha256|sha512|_hash|hash_|_digest|digest_|checksum"
r"|fingerprint|_manager|_client|_service|_provider)"),
# {0,64} because redis://:password@host has an empty user. The entropy floor is
# lower here: a human-chosen password of 8 to 11 characters cannot reach 3.5.
Detector("url-password",
r"://[^\s:/@\"']{0,64}:([^\s:/@\"']{8,})@",
group=1, generic=True, min_entropy=2.5, require_mixed=False),
)
# One combined pass used to skip lines that cannot contain a secret. It is a strict
# superset of the detectors (the private-key entry is reduced to its opening marker so
# that it matches a raw JSONL line, where the newlines are still escaped), so a line
# that fails the prefilter provably has no match. This is what makes 1 GB tractable.
_PREFILTER = re.compile(
"|".join(
r"-----BEGIN [A-Z ]*PRIVATE KEY" if d.kind == "private-key"
# Word-boundary anchors are stripped here. In a raw JSONL line "\n" is the two
# characters \ and n, so "n" before "AKIA" is not a word boundary and the
# anchored pattern rejected the line before it was ever decoded.
else re.sub(r"\\b", "", d.pattern)
for d in DETECTORS
),
re.I,
)
_COMPILED = tuple(
(d, re.compile(d.pattern, d.flags), re.compile(d.exclude) if d.exclude else None)
for d in DETECTORS
)
# Strings that mark a match as documentation rather than a live credential.
# Every marker here must be long enough that it will not occur by chance inside a real
# credential. A bare "xxx" was tried and rejected: it appears in roughly 1 in 570 random
# 60-character alphanumeric strings, so it silently drops genuine keys.
_PLACEHOLDER_MARKERS = ("...", "<", ">", "redacted", "your-", "your_", "yourkey",
"example", "changeme", "placeholder", "dummy", "put-your",
"notreal", "fake-", "sample-", "replace-me")
# Five or more identical alphanumeric characters in a row is filler (XXXXXXXX,
# aaaaaaaa), and is the safe way to catch what a short "xxx" marker was reaching for.
# Restricted to alphanumerics on purpose: the "-----" in a PEM header is structural,
# not filler, and an unrestricted run rule swallows every private key.
_FILLER_RUN = re.compile(r"([A-Za-z0-9])\1{4,}")
MIN_ENTROPY = 3.5
# Characters that never appear inside a credential but are everywhere in source code.
# Structural characters only. An earlier, wider set included ! # $ % @ * ? ^ ~ and threw
# away genuine passwords like S3cr3t!Pass#2024 and URL-encoded p%40ssW0rd.
_CODE_CHARS = set("()[]{}<>`\\;|")
def looks_like_code_or_path(value: str, require_mixed: bool = True) -> bool:
"""Reject source code and filesystem paths that the generic detectors pick up.
This exists because of what a real scan turned up. `api_key = get_api_key()` was
matching, capturing the function call as the secret. Replacement is line-wide, so
that also rewrote `def get_api_key():` further along the same line: source code
quoted in a transcript was being corrupted to scrub a secret that was never there.
Before this guard, 1068 of 1249 findings on a real corpus were code identifiers.
"""
if any(c in _CODE_CHARS for c in value):
return True
if value.startswith("/") or "://" in value or value.endswith((".py", ".ts", ".js", ".json")):
return True
# Practically every real credential mixes letters and digits. An all-alphabetic
# snake_case run is an identifier, and an all-numeric one is an id or a timestamp.
# Only applied where a bare identifier is plausible, which is an assignment. The
# password slot of a URL and the value after "Bearer" are structural credential
# positions: a digitless password there is a password, not a variable name.
if require_mixed and (not any(c.isdigit() for c in value)
or not any(c.isalpha() for c in value)):
return True
return False
def shannon_entropy(s: str) -> float:
"""Bits of entropy per character."""
if not s:
return 0.0
counts = Counter(s)
n = len(s)
return -sum((c / n) * math.log2(c / n) for c in counts.values())
def looks_like_placeholder(value: str) -> bool:
"""True for documentation stand-ins: sk-ant-XXXX, <YOUR_KEY>, aaaa..., API_KEY=...."""
low = value.lower()
if any(m in low for m in _PLACEHOLDER_MARKERS):
return True
if _FILLER_RUN.search(value):
return True
# A value that is mostly one repeated character is a filler, not a credential.
return max(Counter(value).values()) > len(value) / 2
@dataclass(frozen=True)
class Finding:
kind: str
value: str
def find_secrets(text: str, allowlist: Optional[Set[str]] = None) -> List[Finding]:
"""Return the secrets in a single decoded string, in order, deduplicated.
Detectors are applied in declaration order and a match is discarded if it overlaps
a span already claimed, so `sk-ant-...` is reported once as an Anthropic key rather
than also as a generic assignment.
"""
if not text or not _PREFILTER.search(text):
return []
claimed: List[Tuple[int, int]] = []
out: List[Finding] = []
seen: Set[Tuple[str, str]] = set()
for detector, rx, exclude_rx in _COMPILED:
for m in rx.finditer(text):
if exclude_rx is not None:
# Widen to the whole identifier. The pattern starts at the keyword, so
# the disqualifying part of "nextPageToken" sits before the match.
back = m.start()
while back > 0 and (text[back - 1].isalnum() or text[back - 1] in "_.-"):
back -= 1
if exclude_rx.search(text[back:m.end()]):
continue
start, end = m.span(detector.group)
if any(start < c_end and c_start < end for c_start, c_end in claimed):
continue
value = m.group(detector.group)
if not value or looks_like_placeholder(value):
continue
# A "(" straight after the value means it is a function call, not a
# credential. Minified identifiers like nonEmptyString$6 otherwise clear
# the length, digit and entropy checks all at once.
if detector.generic and text[end:end + 1] == "(":
continue
floor = detector.min_entropy or MIN_ENTROPY
if detector.generic and (shannon_entropy(value) < floor
or looks_like_code_or_path(value, detector.require_mixed)):
continue
if allowlist and value in allowlist:
continue
claimed.append(m.span())
key = (detector.kind, value)
if key not in seen:
seen.add(key)
out.append(Finding(detector.kind, value))
return out
# ------------------------------------------------------------------------ placeholders
def load_salt(no_salt: bool = False) -> bytes:
"""Per-machine random salt, so a truncated hash of a low-entropy secret cannot be
brute-forced back. --no-salt gives IDs that are stable across machines instead."""
if no_salt:
return b""
path = Path.home() / ".config" / "redact-transcripts" / "salt"
if path.exists():
return path.read_bytes()
path.parent.mkdir(parents=True, exist_ok=True)
salt = os.urandom(16)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as fh:
fh.write(salt)
return salt
def placeholder(secret: str, kind: str, salt: bytes = b"") -> str:
"""`[REDACTED:github-token:a3f91c]`. The digest keeps the transcript readable and
lets you tell two different secrets apart, without revealing either."""
digest = hashlib.sha256(salt + secret.encode("utf-8", "surrogateescape")).hexdigest()[:6]
return "[REDACTED:%s:%s]" % (kind, digest)
def context_snippet(text: str, secret: str, kind: str, salt: bytes = b"", width: int = 40) -> str:
"""Surrounding characters with the secret already replaced. Safe to print."""
i = text.find(secret)
if i < 0:
return placeholder(secret, kind, salt)
lead, tail = text[max(0, i - width):i], text[i + len(secret):i + len(secret) + width]
snippet = "%s%s%s" % (lead, placeholder(secret, kind, salt), tail)
# Redact the window too. Secrets cluster: an exported key sits next to the token on
# the next line, so showing raw context around one match leaks its neighbour.
snippet = redact_text(snippet, salt)[0]
snippet = snippet.replace("\n", "\\n").replace("\r", "\\r")
return ("..." if i > width else "") + snippet + "..."
# ---------------------------------------------------------------------- string walking
def walk_strings(obj) -> Iterator[str]:
"""Yield every string value in a parsed JSON document. Dict keys are skipped.
Schema-agnostic on purpose. Claude's `toolUseResult` is an object, a bare string or
an array depending on which tool ran, and `tool_result.content` is a string or an
array, so targeting known paths breaks constantly.
"""
stack = [obj]
while stack:
cur = stack.pop()
if isinstance(cur, str):
yield cur
elif isinstance(cur, dict):
# Values only. Redacting a key would rename a field and change the
# transcript's schema, and no agent stores a credential as a JSON key.
stack.extend(cur.values())
elif isinstance(cur, (list, tuple)):
stack.extend(cur)
def find_secrets_in_obj(obj, allowlist: Optional[Set[str]] = None) -> List[Finding]:
out: List[Finding] = []
seen: Set[Tuple[str, str]] = set()
for s in walk_strings(obj):
for f in find_secrets(s, allowlist):
key = (f.kind, f.value)
if key not in seen:
seen.add(key)
out.append(f)
return out
# -------------------------------------------------------------------- line redaction
def _encodings(secret: str) -> List[str]:
"""Every on-disk spelling of one secret, longest first.
A secret found in a decoded string may appear in the raw line as-is, JSON-escaped
(a private key's real newlines become `\\n`), or double-escaped (Codex stores tool
arguments as a JSON string inside JSON).
"""
once = json.dumps(secret, ensure_ascii=False)[1:-1]
twice = json.dumps(once, ensure_ascii=False)[1:-1]
# Some encoders additionally escape the forward slash. Python's does not, so this
# spelling has to be built by hand or a secret containing "/" is silently missed.
variants = {secret, once, twice}
variants |= {v.replace("/", "\\/") for v in list(variants) if "/" in v}
return sorted(variants, key=len, reverse=True)
def redact_text(text: str, salt: bytes = b"", allowlist: Optional[Set[str]] = None
) -> Tuple[str, List[Finding]]:
"""Redact a plain (non-JSON) string."""
findings = find_secrets(text, allowlist)
for f in sorted(findings, key=lambda f: -len(f.value)):
text = text.replace(f.value, placeholder(f.value, f.kind, salt))
return text, findings
def redact_line(raw: str, salt: bytes = b"", allowlist: Optional[Set[str]] = None
) -> Tuple[str, List[Finding]]:
"""Redact one JSONL line, returning the new line and what was found.
Secrets are located in the *decoded* value but substituted into the *raw* line, and
the line is never re-serialized. Python's json.dumps does not reproduce another
encoder's output byte for byte (`1e-07` vs `1e-7`, float formatting, ensure_ascii),
so re-serializing would rewrite a gigabyte of lines that contain no secrets at all.
Substituting into the raw text means clean lines stay byte-identical, line counts
are preserved by construction, and a changed line differs only at the secret.
"""
if not raw.strip() or not _PREFILTER.search(raw):
return raw, []
try:
obj = json.loads(raw)
except ValueError:
return redact_text(raw, salt, allowlist)
findings = find_secrets_in_obj(obj, allowlist)
if not findings:
return raw, []
out = raw
# Longest first. A masked display such as "sk-ant-api03-abcd..." sits next to the
# full key in `auth status` output; replacing the short prefix first consumed the
# key's head and left the remaining 70 characters sitting in the file.
for f in sorted(findings, key=lambda f: -len(f.value)):
token = placeholder(f.value, f.kind, salt)
for enc in _encodings(f.value):
out = out.replace(enc, token)
# Verify against the DECODED result, not the raw text. Raw substitution can miss a
# spelling this code does not know about, and reporting a finding while leaving the
# credential on disk is the worst possible outcome: the user believes it is clean.
try:
if find_secrets_in_obj(json.loads(out), allowlist):
raise ValueError("secret survived raw substitution")
except ValueError:
# ensure_ascii=True on purpose: a transcript can carry a lone surrogate (what
# JSON.stringify emits for a truncated emoji). Writing that raw produces bytes
# that will not decode as UTF-8, or raises and kills the whole run.
out = json.dumps(_redact_obj(obj, salt, allowlist), ensure_ascii=True)
return out, findings
def _redact_obj(obj, salt: bytes, allowlist: Optional[Set[str]]):
if isinstance(obj, str):
return redact_text(obj, salt, allowlist)[0]
if isinstance(obj, dict):
return {k: _redact_obj(v, salt, allowlist) for k, v in obj.items()}
if isinstance(obj, list):
return [_redact_obj(v, salt, allowlist) for v in obj]
return obj
# ------------------------------------------------------------------------- file layer
@dataclass
class FileStats:
path: Path
findings: int = 0
by_kind: Counter = field(default_factory=Counter)
samples: List[str] = field(default_factory=list)
changed: bool = False
error: str = ""
def redact_file(path: Path, apply: bool = False, backup_dir: Optional[Path] = None,
salt: bytes = b"", allowlist: Optional[Set[str]] = None,
collect_context: bool = False, max_samples: int = 3) -> FileStats:
"""Scan one file, and rewrite it in place when `apply` is set.
The file is only touched if something was found, so a clean transcript keeps its
mtime. Rewrites are atomic and restore the original mode and mtime, because
`/resume` orders sessions by mtime and distill.py's manifest is mtime-keyed.
"""
stats = FileStats(path=path)
# Write through to the real file. os.replace on a symlink swaps out the LINK, which
# would leave the actual transcript unredacted while looking like it worked.
path = Path(os.path.realpath(str(path)))
is_jsonl = path.suffix == ".jsonl"
try:
with open(path, "r", encoding="utf-8", errors="surrogateescape", newline="") as fh:
original = fh.read()
except (OSError, UnicodeError) as exc:
stats.error = str(exc)
return stats
pieces: List[str] = []
if is_jsonl:
# Split on "\n" only. str.splitlines() also breaks on U+2028, U+2029 and
# U+0085, which are legal characters inside a JSON string, so it would chop a
# valid record into fragments that no longer parse.
raw_lines = original.split("\n")
for idx, body in enumerate(raw_lines):
sep = "\n" if idx < len(raw_lines) - 1 else ""
new, found = redact_line(body, salt, allowlist)
if found:
_record(stats, found, body, salt, collect_context, max_samples)
pieces.append(new + sep)
updated = "".join(pieces)
else:
updated, found = redact_line(original, salt, allowlist)
if found:
_record(stats, found, original, salt, collect_context, max_samples)
if not stats.findings or updated == original:
return stats
if not apply:
return stats
st = path.stat()
tmp = None
try:
if backup_dir is not None:
dest = backup_dir / Path(str(path).lstrip("/"))
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(path), str(dest))
if st.st_nlink > 1:
# Rewrite through the existing inode. os.replace would hand this path a new
# one, and every sibling hardlink would quietly keep the original secret.
with open(path, "w", encoding="utf-8", errors="surrogateescape", newline="") as fh:
fh.write(updated)
fh.flush()
os.fsync(fh.fileno())
else:
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".redact-", suffix=".tmp")
with os.fdopen(fd, "w", encoding="utf-8", errors="surrogateescape", newline="") as fh:
fh.write(updated)
fh.flush()
os.fsync(fh.fileno())
os.chmod(tmp, st.st_mode & 0o777)
os.replace(tmp, str(path))
tmp = None
os.utime(str(path), (st.st_atime, st.st_mtime))
except (OSError, UnicodeError) as exc:
# One unwritable file must not abort a run across a thousand of them.
if tmp and os.path.exists(tmp):
try:
os.unlink(tmp)
except OSError:
pass
stats.error = str(exc)
return stats
stats.changed = True
return stats
def _record(stats: FileStats, found: List[Finding], text: str, salt: bytes,
collect_context: bool, max_samples: int) -> None:
stats.findings += len(found)
for f in found:
stats.by_kind[f.kind] += 1
if collect_context and len(stats.samples) < max_samples:
stats.samples.append(context_snippet(text, f.value, f.kind, salt))
# ------------------------------------------------------------------- discovery layer
TEXT_SUFFIXES = {".jsonl", ".json", ".md", ".txt", ".log"}
# The exclusion principle: redact the record of what happened, never what the agent
# reads in order to operate. Rewriting these breaks live tooling. `.claude.json` holds
# `mcpServers.*.env` keys the MCP servers actually need; shell-snapshots are how Claude
# Code restores the Bash environment.
EXCLUDED_NAMES = {
"auth.json", "auth.v2.file", "auth.v2.key", ".env", ".claude.json",
"settings.json", "settings.local.json", "config.toml", "config.json",
"credentials.json", ".credentials.json", "mcp_config.json",
}
EXCLUDED_SUFFIXES = {".key", ".pem", ".sh", ".bak", ".tmp"}
EXCLUDED_DIR_PARTS = {
"credentials", "oauth", "shell-snapshots", "node_modules", ".git",
"agent-sdk-venv", "site-packages", ".venv",
}
def is_excluded(path: Path) -> bool:
if path.name in EXCLUDED_NAMES or path.suffix in EXCLUDED_SUFFIXES:
return True
if path.name.endswith(("-key.sh", "-token.sh", ".meta.json")) and path.suffix != ".jsonl":
if path.name.endswith(".meta.json"):
return False
return True
return any(part in EXCLUDED_DIR_PARTS for part in path.parts)
def iter_files(roots: Iterable[Path], skip_active_seconds: float = 0) -> Iterator[Path]:
"""Yield every scannable file under `roots`, deduplicated by realpath.
Deduplication is not an optimization. `~/.claude-personal`, `-work`, `-kimi`,
`-opencode` and `-zai` are all symlinks to the same `projects/` directory, so
without it the same gigabyte gets read and rewritten five times. Recursion matters
too: Claude keeps 322 subagent transcripts at `<slug>/<session>/subagents/`, which
a `*/*.jsonl` glob silently misses.
"""
seen: Set[str] = set()
now = time.time()
for root in roots:
root = Path(root).expanduser()
if not root.exists():
continue
candidates = [root] if root.is_file() else sorted(root.rglob("*"))
for p in candidates:
try:
if not p.is_file() or p.suffix not in TEXT_SUFFIXES or is_excluded(p):
continue
real = os.path.realpath(str(p))
if real in seen:
continue
if skip_active_seconds and (now - p.stat().st_mtime) < skip_active_seconds:
continue
seen.add(real)
except OSError:
continue
yield p
AGENT_ROOTS: Dict[str, List[str]] = {
"claude": ["~/.claude/projects", "~/.claude/history.jsonl",
"~/.claude-personal/paste-cache", "~/.claude-work/paste-cache"],
"codex": ["~/.codex/sessions", "~/.codex/history.jsonl"],
"gemini": ["~/.gemini/tmp", "~/.gemini/antigravity-cli/history.jsonl"],
"kimi": ["~/.kimi-code/sessions", "~/.kimi-code/session_index.jsonl"],
}
# Known additional copies of the same conversations. Not scanned by default: they are
# SQLite (with live WAL files) or very large binary-ish stores. Reported so you know
# redacting the JSONL did not get everything.
SECOND_COPIES = [
("~/.codex/thread_history_1.sqlite", "Codex mirrors every rollout item in thread_items.item_json"),
("~/.claude/file-history/", "Claude keeps pre-edit copies of files it modified"),
("~/.local/share/opencode/opencode.db", "OpenCode stores whole sessions in SQLite"),
("~/.playwriter/cdp.jsonl", "Playwriter logs every CDP message, including revealed tokens"),
("~/personal/claude-session-graph/distilled/", "Distilled markdown copies of Claude sessions"),
]
# ------------------------------------------------------------------------------- CLI
def human(n: float) -> str:
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return "%.1f %s" % (n, unit)
n /= 1024
return "%.1f GB" % n
def run(roots: List[Path], apply: bool, backup_dir: Optional[Path], salt: bytes,
allowlist: Set[str], show_context: bool, skip_active: float) -> dict:
totals: Counter = Counter()
files_with, files_changed, scanned, byte_count = [], 0, 0, 0
errors: List[Tuple[Path, str]] = []
started = time.time()
for path in iter_files(roots, skip_active_seconds=skip_active):
scanned += 1
try:
byte_count += path.stat().st_size
except OSError:
pass
st = redact_file(path, apply=apply, backup_dir=backup_dir, salt=salt,
allowlist=allowlist, collect_context=show_context)
if st.error:
errors.append((path, st.error))
if st.findings:
totals.update(st.by_kind)
files_with.append(st)
files_changed += 1 if st.changed else 0
return {
"scanned_files": scanned,
"scanned_bytes": byte_count,
"files_with_findings": len(files_with),
"files_rewritten": files_changed,
"total_findings": sum(totals.values()),
"by_kind": dict(totals.most_common()),
"elapsed_seconds": round(time.time() - started, 2),
"errors": ["%s: %s" % (p, e) for p, e in errors],
"_details": files_with,
}
def self_test() -> int:
"""Build a synthetic corpus in every supported envelope, then prove round-trip."""
import random, string as _s
rng = random.Random(20260902)
def body(n, alphabet=_s.ascii_letters + _s.digits):
return "".join(rng.choice(alphabet) for _ in range(n))
secrets = ["sk-ant-api03-" + body(95), "ghp_" + body(36), "sk-proj-" + body(48),
"AKIA" + body(16, _s.ascii_uppercase + _s.digits),
"AIza" + body(35), "ATATT" + body(60), "hf_" + body(34),
"eyJ" + body(30) + "." + body(40) + "." + body(43)]
pem = ("-----BEGIN RSA PRIVATE KEY-----\n"
+ "\n".join(body(64) for _ in range(4)) + "\n-----END RSA PRIVATE KEY-----")
secrets.append(pem)
envelopes = [
lambda s: {"type": "user", "message": {"role": "user", "content": [
{"type": "tool_result", "content": "$ env\nKEY=%s\n" % s}]},
"toolUseResult": {"stdout": "KEY=%s\n" % s, "stderr": ""}},
lambda s: {"toolUseResult": [{"type": "text", "text": "value %s" % s}]},
lambda s: {"toolUseResult": s},
lambda s: {"type": "response_item", "payload": {
"type": "function_call", "arguments": json.dumps({"cmd": "export K=%s" % s})}},
lambda s: {"$set": {"messages": [{"content": [{"text": "use %s" % s}]}]}},
lambda s: {"event": {"result": {"output": "token: %s" % s}}, "f": 1e-7},
]
tmp = Path(tempfile.mkdtemp(prefix="redact-selftest-"))
failures: List[str] = []
try:
target = tmp / "corpus.jsonl"
lines, clean = [], {"note": "no secret here", "float": 1e-7, "u": "caf\u00e9"}
for s in secrets:
for env in envelopes:
lines.append(json.dumps(env(s)))
lines.append(json.dumps(clean))
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
before = target.read_text(encoding="utf-8").splitlines()
os.utime(str(target), (0, 0))
st = redact_file(target, apply=True, backup_dir=tmp / "bk", salt=b"selftest")
after = target.read_text(encoding="utf-8").splitlines()
text = "\n".join(after)
if len(before) != len(after):
failures.append("line count changed: %d -> %d" % (len(before), len(after)))
for i, line in enumerate(after):
try:
json.loads(line)
except ValueError as exc:
failures.append("line %d no longer valid JSON: %s" % (i, exc))
break
for s in secrets:
if s in text:
failures.append("MISSED a %s" % find_secrets(s)[0].kind if find_secrets(s) else "MISSED a secret")
if "BEGIN RSA PRIVATE KEY" in text:
failures.append("private key block survived")
expected = len(secrets) * len(envelopes)
if st.findings != expected:
failures.append("expected %d findings, got %d" % (expected, st.findings))
# clean lines must be byte-identical
for a, b in zip(before, after):
if "REDACTED" not in b and a != b:
failures.append("a clean line was rewritten")
break
# idempotence
st2 = redact_file(target, apply=True, backup_dir=tmp / "bk2", salt=b"selftest")
if st2.findings:
failures.append("not idempotent: %d findings on second pass" % st2.findings)
# the backup must still hold the originals
bk = list((tmp / "bk").rglob("corpus.jsonl"))
if not bk or secrets[0] not in bk[0].read_text(encoding="utf-8"):
failures.append("backup did not preserve the original")
finally:
shutil.rmtree(str(tmp), ignore_errors=True)
if failures:
for f in failures:
print("FAIL: %s" % f, file=sys.stderr)
return 1
print("self-test passed: %d secret shapes x %d envelope shapes, round-trip clean"
% (len(secrets), len(envelopes)))
return 0
def main(argv: Optional[List[str]] = None) -> int:
ap = argparse.ArgumentParser(
prog="redact_agent_transcripts.py",
description="Redact secrets from Claude Code, Codex, Gemini CLI and Kimi Code transcripts.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Default is a dry run. Nothing is written without --apply.")
ap.add_argument("--agent", default="all",
help="comma-separated: claude,codex,gemini,kimi,all (default: all)")
ap.add_argument("--paths", nargs="+", metavar="PATH",
help="scan these files/directories instead of the known agent stores")
ap.add_argument("--apply", action="store_true", help="rewrite files in place (backs up first)")
ap.add_argument("--backup-dir", type=Path, default=None,
help="default: ~/.redact-backups/<timestamp>/")
ap.add_argument("--show-context", action="store_true",
help="print surrounding text with the secret already replaced")
ap.add_argument("--allowlist", type=Path, help="file of literal values to never redact")
ap.add_argument("--include-active", action="store_true",
help="also scan files modified in the last 60s (the live session)")
ap.add_argument("--no-salt", action="store_true",
help="unsalted digests, stable across machines")
ap.add_argument("--json", action="store_true", help="machine-readable summary on stdout")
ap.add_argument("--self-test", action="store_true", help="run built-in round-trip checks")
ap.add_argument("--version", action="version", version=__version__)
args = ap.parse_args(argv)
if args.self_test:
return self_test()
if args.paths:
roots = [Path(p).expanduser() for p in args.paths]
agents = ["custom"]
else:
agents = sorted(AGENT_ROOTS) if args.agent == "all" else \
[a.strip() for a in args.agent.split(",") if a.strip()]
unknown = [a for a in agents if a not in AGENT_ROOTS]
if unknown:
ap.error("unknown agent(s): %s" % ", ".join(unknown))
roots = [Path(r).expanduser() for a in agents for r in AGENT_ROOTS[a]]
allowlist = set()
if args.allowlist:
allowlist = {l.strip() for l in args.allowlist.read_text().splitlines()
if l.strip() and not l.startswith("#")}
salt = load_salt(args.no_salt)
backup_dir = args.backup_dir
if args.apply and backup_dir is None:
backup_dir = Path.home() / ".redact-backups" / time.strftime("%Y%m%d-%H%M%S")
result = run(roots, apply=args.apply, backup_dir=backup_dir, salt=salt,
allowlist=allowlist, show_context=args.show_context,
skip_active=0 if args.include_active else 60)
details: List[FileStats] = result.pop("_details")
if args.json:
print(json.dumps(result, indent=2))
else:
print("agents: %s" % ", ".join(agents))
print("scanned: %d files, %s in %.1fs"
% (result["scanned_files"], human(result["scanned_bytes"]),
result["elapsed_seconds"]))
print("findings: %d in %d files"
% (result["total_findings"], result["files_with_findings"]))
if result["by_kind"]:
print()
width = max(len(k) for k in result["by_kind"])
for kind, n in result["by_kind"].items():
print(" %-*s %d" % (width, kind, n))
if args.show_context and details:
print("\ncontext (secrets already replaced):")
for st in details[:20]:
print("\n %s" % st.path)
for s in st.samples:
print(" %s" % s)
if details and not args.apply:
print("\nfiles with findings:")
for st in sorted(details, key=lambda s: -s.findings)[:15]:
print(" %4d %s" % (st.findings, st.path))
if len(details) > 15:
print(" ... and %d more" % (len(details) - 15))
extant = [(p, why) for p, why in SECOND_COPIES if Path(p).expanduser().exists()]
if extant:
print("\nnot scanned, but holds a second copy of the same conversations:")
for p, why in extant:
print(" %-42s %s" % (p, why))
print(" scan these with --paths, or accept that the JSONL pass missed them.")
if result["errors"]:
print("\ncould not be processed (%d):" % len(result["errors"]))
for e in result["errors"][:10]:
print(" %s" % e)
if args.apply:
print("\nrewrote %d files." % result["files_rewritten"])
print("If an agent was running, it may hold a session file open and rewrite")
print("it from memory. Close your agents and re-run to be sure.")
if backup_dir:
print("BACKUPS AT %s STILL CONTAIN THE ORIGINAL SECRETS." % backup_dir)
print("Delete them once you are satisfied: rm -rf %s" % backup_dir)
elif result["total_findings"]:
print("\nDry run. Re-run with --apply to rewrite these files.")
return 1 if (result["total_findings"] and not args.apply) else 0
if __name__ == "__main__":
sys.exit(main())
# /// script
# requires-python = ">=3.11"
# dependencies = ["pytest"]
# ///
"""Tests for redact_agent_transcripts.
Fake secrets are BUILT AT RUNTIME by concatenating a prefix with a deterministic
pseudo-random body. No well-formed credential literal appears in this source, so the
file will not trip GitHub push protection or secret scanners.
"""
import json
import os
import random
import string
import subprocess
import sys
import time
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent))
import redact_agent_transcripts as R
def fake(n, seed, alphabet=string.ascii_letters + string.digits):
"""Deterministic pseudo-random body. Mixed case + digits, so it passes the
entropy gate and the 'mostly one character' placeholder filter."""
rng = random.Random(seed)
return "".join(rng.choice(alphabet) for _ in range(n))
# (kind, secret) pairs covering every high-precision detector.
def token_samples():
return [
("anthropic-api-key", "sk-ant-api03-" + fake(95, 1)),
("anthropic-api-key", "sk-ant-oat01-" + fake(80, 2)),
("openai-api-key", "sk-proj-" + fake(48, 3)),
("github-token", "ghp_" + fake(36, 4)),
("github-token", "gho_" + fake(36, 5)),
("github-token", "github_pat_" + fake(72, 6)),
("gitlab-token", "glpat-" + fake(20, 7)),
("aws-access-key-id", "AKIA" + fake(16, 8, string.ascii_uppercase + string.digits)),
("google-api-key", "AIza" + fake(35, 9, string.ascii_letters + string.digits + "_-")),
("google-oauth-token", "ya29." + fake(60, 10, string.ascii_letters + string.digits + "_-")),
("slack-token", "xoxb-" + fake(30, 11, string.digits + "abcdef-")),
("atlassian-token", "ATATT" + fake(60, 12)),
("huggingface-token", "hf_" + fake(34, 13)),
("npm-token", "npm_" + fake(36, 14)),
("stripe-secret-key", "sk_live_" + fake(24, 15)),
("openrouter-key", "sk-or-v1-" + fake(64, 16, string.digits + "abcdef")),
("jwt", "eyJ" + fake(30, 17) + "." + fake(40, 18) + "." + fake(43, 19)),
]
PRIVATE_KEY = (
"-----BEGIN RSA PRIVATE KEY-----\n"
+ "\n".join(fake(64, 100 + i) for i in range(6))
+ "\n-----END RSA PRIVATE KEY-----"
)
# ---------------------------------------------------------------- placeholder
def test_placeholder_format():
p = R.placeholder("some-secret-value", "github-token", salt=b"s")
assert p.startswith("[REDACTED:github-token:")
assert p.endswith("]")
assert len(p.rsplit(":", 1)[1][:-1]) == 6
def test_placeholder_is_stable_and_distinguishes_secrets():
a = R.placeholder("aaa", "k", salt=b"s")
assert a == R.placeholder("aaa", "k", salt=b"s")
assert a != R.placeholder("bbb", "k", salt=b"s")
def test_placeholder_salt_changes_hash():
assert R.placeholder("aaa", "k", salt=b"x") != R.placeholder("aaa", "k", salt=b"y")
def test_placeholder_never_contains_the_secret():
secret = "sk-ant-api03-" + fake(95, 1)
assert secret not in R.placeholder(secret, "anthropic-api-key", salt=b"s")
# ------------------------------------------------------------------ detectors
@pytest.mark.parametrize("kind,secret", token_samples(), ids=[f"{k}-{i}" for i, (k, _) in enumerate(token_samples())])
def test_detects_token_in_prose(kind, secret):
found = R.find_secrets(f"here is the value {secret} ok")
assert [f.kind for f in found] == [kind], f"expected {kind}, got {[f.kind for f in found]}"
assert found[0].value == secret
def test_detects_private_key_block():
found = R.find_secrets("before\n" + PRIVATE_KEY + "\nafter")
assert [f.kind for f in found] == ["private-key"]
assert found[0].value.startswith("-----BEGIN RSA PRIVATE KEY-----")
assert found[0].value.endswith("-----END RSA PRIVATE KEY-----")
def test_detects_bearer_header():
tok = fake(40, 30)
found = R.find_secrets(f'curl -H "Authorization: Bearer {tok}"')
assert [f.kind for f in found] == ["bearer-token"]
assert found[0].value == tok
def test_detects_env_assignment():
val = fake(32, 31)
found = R.find_secrets(f"DATABASE_SECRET={val}")
assert [f.kind for f in found] == ["generic-secret-assignment"]
assert found[0].value == val
def test_detects_url_password_only():
pw = fake(20, 32)
found = R.find_secrets(f"postgres://appuser:{pw}@db.internal:5432/prod")
assert [f.kind for f in found] == ["url-password"]
assert found[0].value == pw
def test_no_false_positive_on_ordinary_transcript_prose():
text = (
"I ran the tests and they passed. The file is at /Users/me/project/src/main.py "
"and the function is called get_token(). See https://example.com/docs/api_key for "
"details. Commit 4b5bb9f2c1a0deadbeefcafe1234567890abcdef fixed it."
)
assert R.find_secrets(text) == []
def test_placeholder_values_are_not_flagged():
for junk in [
"sk-ant-api03-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"ghp_your-token-here-replace-me-with-real-value",
"AIza<YOUR_API_KEY_HERE_PLEASE_REPLACE_NOW>",
"sk-proj-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"API_KEY=................................",
]:
assert R.find_secrets(junk) == [], f"should have been filtered: {junk[:20]}"
def test_already_redacted_text_is_not_reflagged():
once = R.redact_text("token is " + "ghp_" + fake(36, 4))[0]
assert R.find_secrets(once) == []
def test_low_entropy_generic_assignment_is_ignored():
assert R.find_secrets("SECRET=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") == []
assert R.find_secrets("TOKEN=abababababababababababababab") == []
def test_short_values_are_ignored():
assert R.find_secrets("PASSWORD=hunter2") == []
assert R.find_secrets("postgres://user:pw@host/db") == []
def test_code_is_not_mistaken_for_a_secret():
"""Regression: these are verbatim shapes from a real transcript scan.
The generic assignment detector was capturing function calls and identifiers. Because
replacement is line-wide, capturing `get_ocr_api_key()` also rewrote
`def get_ocr_api_key():` elsewhere on the same line, corrupting source
code that happened to be quoted in the transcript.
"""
for line in [
" client_secret = config.get_client_secret()",
" api_key = get_ocr_api_key()",
" return os.getenv('PAYMENTS_API_KEY')",
"Error refreshing access token: HTTPSConnectionPool(host='oauth2.googleapis.com')",
"from .security import hash_api_key",
" api_key: Optional[str] = Field(default=None)",
" -H \"X-API-Key: your-api-key\" \\",
"const apiKey = process.env.UPLOADS_API_SECRET;",
"self.api_key = self._resolve_api_key_from_environment",
" api_key = credentials.get_secret_value()",
]:
assert R.find_secrets(line) == [], f"false positive on code: {line.strip()[:60]}"
def test_paths_and_urls_are_not_mistaken_for_secrets():
for line in [
"token_path = /Users/omar/work/project/config/tokens.json",
"api_key_docs: https://example.com/docs/authentication/api_keys",
"SECRET_DIR=/opt/homebrew/var/secrets/production",
]:
assert R.find_secrets(line) == [], f"false positive: {line[:60]}"
def test_generic_detector_still_catches_real_assignments():
"""The precision fixes must not cost recall on genuine credential assignments."""
real = [
("GEMINI_API_KEY=" + fake(39, 200), "generic-secret-assignment"),
('"client_secret": "' + fake(32, 201) + '"', "generic-secret-assignment"),
("export DATABASE_PASSWORD=" + fake(24, 202), "generic-secret-assignment"),
("?key=abc&token=" + fake(40, 203), "generic-secret-assignment"),
]
for line, kind in real:
found = R.find_secrets(line)
assert [f.kind for f in found] == [kind], f"missed a real secret in: {line[:30]}"
def test_all_lowercase_identifier_is_not_a_secret():
# No digits: essentially every real credential has at least one.
assert R.find_secrets("api_key = resolve_secret_from_vault_backend") == []
def test_allowlist_suppresses_a_specific_value(monkeypatch):
secret = "ghp_" + fake(36, 4)
assert R.find_secrets(f"x {secret} y") != []
assert R.find_secrets(f"x {secret} y", allowlist={secret}) == []
# ------------------------------------------------------------- string walking
def test_walk_finds_strings_in_nested_containers():
obj = {"a": "one", "b": ["two", {"c": "three"}], "d": {"e": ["four"]}, "n": 1, "z": None}
assert set(R.walk_strings(obj)) == {"one", "two", "three", "four"}
def test_walk_skips_dict_keys():
# Redacting a key would rename a JSON field and corrupt the transcript schema.
assert set(R.walk_strings({"the-key": "v"})) == {"v"}
def test_finds_secret_in_claude_tool_result_string_shape():
secret = "ghp_" + fake(36, 4)
line = {
"type": "user",
"message": {"role": "user", "content": [
{"tool_use_id": "toolu_1", "type": "tool_result",
"content": f"total 4\nGITHUB_TOKEN={secret}\n", "is_error": False}]},
"toolUseResult": {"stdout": f"GITHUB_TOKEN={secret}\n", "stderr": "", "interrupted": False},
}
found = R.find_secrets_in_obj(line)
assert {f.value for f in found} == {secret}
def test_finds_secret_in_claude_tool_result_array_shape():
secret = "AKIA" + fake(16, 8, string.ascii_uppercase + string.digits)
line = {"toolUseResult": [{"type": "text", "text": f"key: {secret}"}]}
assert {f.value for f in R.find_secrets_in_obj(line)} == {secret}
def test_finds_secret_in_bare_string_tool_result():
secret = "hf_" + fake(34, 13)
assert {f.value for f in R.find_secrets_in_obj({"toolUseResult": secret})} == {secret}
def test_finds_secret_in_codex_double_encoded_arguments():
secret = "sk-ant-api03-" + fake(95, 1)
inner = json.dumps({"cmd": f'export ANTHROPIC_API_KEY={secret}\necho "$ANTHROPIC_API_KEY"'})
line = {"type": "response_item",
"payload": {"type": "function_call", "name": "exec_command", "arguments": inner}}
assert {f.value for f in R.find_secrets_in_obj(line)} == {secret}
def test_finds_secret_in_gemini_set_delta():
secret = "AIza" + fake(35, 9, string.ascii_letters + string.digits + "_-")
line = {"$set": {"messages": [{"type": "user", "content": [{"text": f"use {secret}"}]}]}}
assert {f.value for f in R.find_secrets_in_obj(line)} == {secret}
# -------------------------------------------------------------- line redaction
def test_redact_line_removes_secret_and_keeps_valid_json():
secret = "ghp_" + fake(36, 4)
raw = json.dumps({"toolUseResult": {"stdout": f"TOKEN={secret}\n"}})
out, found = R.redact_line(raw)
assert secret not in out
assert len(found) == 1
assert json.loads(out)["toolUseResult"]["stdout"].startswith("TOKEN=[REDACTED:github-token:")
def test_redact_line_handles_double_encoded_arguments():
secret = "sk-proj-" + fake(48, 3)
inner = json.dumps({"cmd": f"echo {secret}"})
raw = json.dumps({"payload": {"arguments": inner}})
out, found = R.redact_line(raw)
assert secret not in out
reparsed = json.loads(json.loads(out)["payload"]["arguments"])
assert "[REDACTED:openai-api-key:" in reparsed["cmd"]
def test_redact_line_handles_embedded_newlines_in_private_key():
raw = json.dumps({"toolUseResult": {"stdout": PRIVATE_KEY}})
out, found = R.redact_line(raw)
assert [f.kind for f in found] == ["private-key"]
assert "BEGIN RSA PRIVATE KEY" not in out
assert json.loads(out)["toolUseResult"]["stdout"].startswith("[REDACTED:private-key:")
def test_redact_line_leaves_clean_line_byte_identical():
raw = json.dumps({"a": 1e-7, "b": "hello", "c": [1.0, 2.5], "d": "café"})
out, found = R.redact_line(raw)
assert found == []
assert out == raw, "clean lines must not be re-serialized"
def test_redact_line_replaces_every_occurrence_of_the_same_secret():
secret = "glpat-" + fake(20, 7)
raw = json.dumps({"a": f"x {secret}", "b": f"y {secret}", "c": {"d": secret}})
out, _ = R.redact_line(raw)
assert secret not in out
assert out.count("[REDACTED:gitlab-token:") == 3
def test_redact_line_is_idempotent():
secret = "ATATT" + fake(60, 12)
raw = json.dumps({"v": f"token {secret}"})
once, f1 = R.redact_line(raw)
twice, f2 = R.redact_line(once)
assert f1 and not f2
assert once == twice
def test_redact_line_handles_escaped_forward_slash():
"""Some JSON encoders write `/` as `\\/`. The secret is then on disk in a spelling
none of the three encodings match, and the tool reported a finding while leaving the
credential in place: the worst failure mode, because the user believes it is clean."""
secret = "aB3d/Ef7gH" + fake(20, 40)
raw = '{"v":"API_KEY=' + secret.replace("/", "\\/") + '"}'
out, found = R.redact_line(raw)
assert found, "should still detect it"
assert secret not in json.loads(out)["v"], "secret survived in the decoded output"
def test_redact_line_survives_an_unknown_escaping_scheme():
"""General safety net: whatever the encoder did, the decoded output must be clean."""
secret = "ghp_" + fake(36, 4)
# A secret spelled with a \u escape for one of its characters.
escaped = "ghp_" + "\\u0041" + secret[5:]
raw = '{"v":"' + escaped + '"}'
decoded_secret_chars = json.loads(raw)["v"]
out, found = R.redact_line(raw)
if found:
assert not R.find_secrets(json.loads(out)["v"]), "decoded output still holds a secret"
def test_redact_line_passes_through_non_json():
raw = "this is not json at all"
out, found = R.redact_line(raw)
assert out == raw and found == []
def test_redact_line_still_scrubs_secret_in_unparseable_line():
secret = "ghp_" + fake(36, 4)
raw = "{broken json " + secret
out, found = R.redact_line(raw)
assert secret not in out and len(found) == 1
def test_redact_line_preserves_unicode_escaping_style():
secret = "npm_" + fake(36, 14)
raw = json.dumps({"msg": f"café {secret}"}, ensure_ascii=True)
out, _ = R.redact_line(raw)
assert secret not in out
assert "\\u00e9" in out, "must not rewrite the rest of the line's escaping"
assert json.loads(out)["msg"].startswith("café ")
# ------------------------------------------------------------------ file layer
def write_jsonl(path, objs):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(json.dumps(o) for o in objs) + "\n")
def test_redact_file_preserves_line_count_and_parses(tmp_path):
secret = "ghp_" + fake(36, 4)
p = tmp_path / "s.jsonl"
objs = [{"i": i, "v": f"val {secret}" if i % 3 == 0 else "clean"} for i in range(30)]
write_jsonl(p, objs)
before = p.read_text().splitlines()
stats = R.redact_file(p, apply=True, backup_dir=tmp_path / "bk")
after = p.read_text().splitlines()
assert len(before) == len(after) == 30
assert all(json.loads(l) for l in after)
assert stats.findings == 10
assert secret not in p.read_text()
def test_redact_file_only_changes_lines_with_secrets(tmp_path):
secret = "hf_" + fake(34, 13)
p = tmp_path / "s.jsonl"
objs = [{"i": i, "f": 1e-7, "v": secret if i == 5 else "clean"} for i in range(10)]
write_jsonl(p, objs)
before = p.read_text().splitlines()
R.redact_file(p, apply=True, backup_dir=tmp_path / "bk")
after = p.read_text().splitlines()
changed = [i for i, (a, b) in enumerate(zip(before, after)) if a != b]
assert changed == [5]
def test_redact_file_dry_run_does_not_write(tmp_path):
secret = "ghp_" + fake(36, 4)
p = tmp_path / "s.jsonl"
write_jsonl(p, [{"v": secret}])
original = p.read_text()
stats = R.redact_file(p, apply=False, backup_dir=tmp_path / "bk")
assert stats.findings == 1
assert p.read_text() == original
assert not (tmp_path / "bk").exists()
def test_redact_file_makes_backup_preserving_relative_path(tmp_path):
secret = "ghp_" + fake(36, 4)
p = tmp_path / "deep" / "nest" / "s.jsonl"
write_jsonl(p, [{"v": secret}])
original = p.read_text()
bk = tmp_path / "bk"
R.redact_file(p, apply=True, backup_dir=bk)
backups = list(bk.rglob("s.jsonl"))
assert len(backups) == 1
assert backups[0].read_text() == original
assert "deep/nest" in str(backups[0])
def test_redact_file_preserves_mode_and_mtime(tmp_path):
secret = "ghp_" + fake(36, 4)
p = tmp_path / "s.jsonl"
write_jsonl(p, [{"v": secret}])
os.chmod(p, 0o640)
old = p.stat()
os.utime(p, (old.st_atime, old.st_mtime - 10_000))
want = p.stat().st_mtime
R.redact_file(p, apply=True, backup_dir=tmp_path / "bk")
new = p.stat()
assert new.st_mode & 0o777 == 0o640
assert abs(new.st_mtime - want) < 1
def test_redact_file_follows_symlink_without_destroying_it(tmp_path):
"""os.replace on a symlink replaces the LINK with a regular file, leaving the real
transcript unredacted. Claude's alt-homes symlink history.jsonl, so this is reachable."""
secret = "ghp_" + fake(36, 4)
real = tmp_path / "real.jsonl"
write_jsonl(real, [{"v": f"TOKEN={secret}"}])
link = tmp_path / "link.jsonl"
link.symlink_to(real)
os.utime(real, (0, 0))
R.redact_file(link, apply=True, backup_dir=tmp_path / "bk")
assert link.is_symlink(), "the symlink was replaced by a regular file"
assert secret not in real.read_text(), "the real file behind the symlink was not redacted"
def test_redact_file_leaves_clean_file_untouched(tmp_path):
p = tmp_path / "s.jsonl"
write_jsonl(p, [{"v": "nothing here"} for _ in range(5)])
before_mtime = p.stat().st_mtime
time.sleep(0.01)
stats = R.redact_file(p, apply=True, backup_dir=tmp_path / "bk")
assert stats.findings == 0
assert p.stat().st_mtime == before_mtime
assert not (tmp_path / "bk").exists()
def test_redact_file_handles_plain_text(tmp_path):
secret = "AKIA" + fake(16, 8, string.ascii_uppercase + string.digits)
p = tmp_path / "notes.md"
p.write_text(f"# notes\nmy key is {secret}\n")
R.redact_file(p, apply=True, backup_dir=tmp_path / "bk")
assert secret not in p.read_text()
assert p.read_text().startswith("# notes\n")
# ------------------------------------------------------------- discovery layer
def test_excluded_paths_are_never_targeted():
for p in [
"~/.codex/auth.json", "~/.claude/settings.json", "~/.claude/settings.local.json",
"~/.claude.json", "~/.claude-work/.claude.json", "~/.gemini/.env",
"~/.claude/zai-key.sh", "~/.claude/sessions/abc.key",
"~/.kimi-code/credentials/kimi-code.json", "~/.kimi-code/oauth/x.json",
"~/.claude-personal/shell-snapshots/snapshot-zsh-1.sh",
"~/.codex/config.toml", "~/.local/share/opencode/auth.json",
]:
assert R.is_excluded(Path(p).expanduser()), f"{p} must be excluded"
def test_transcript_paths_are_not_excluded():
for p in [
"~/.claude/projects/-Users-x/abc.jsonl",
"~/.claude/projects/-Users-x/abc/subagents/agent-1.jsonl",
"~/.claude/history.jsonl",
"~/.codex/sessions/2026/09/02/rollout-x.jsonl",
"~/.kimi-code/sessions/wd_a/session_b/agents/main/wire.jsonl",
"~/.gemini/tmp/proj/chats/session-1.jsonl",
]:
assert not R.is_excluded(Path(p).expanduser()), f"{p} must NOT be excluded"
def test_discovery_finds_nested_subagent_transcripts(tmp_path):
root = tmp_path / "projects"
write_jsonl(root / "-Users-x" / "sess.jsonl", [{"a": 1}])
write_jsonl(root / "-Users-x" / "sess" / "subagents" / "agent-1.jsonl", [{"a": 1}])
found = set(R.iter_files([root]))
assert len(found) == 2, "a */*.jsonl glob would miss the subagent file"
def test_discovery_dedupes_symlinked_homes(tmp_path):
real = tmp_path / "real"
write_jsonl(real / "s.jsonl", [{"a": 1}])
link = tmp_path / "link"
link.symlink_to(real)
found = list(R.iter_files([real, link]))
assert len(found) == 1, "symlinked alt-homes must dedupe by realpath"
def test_discovery_skips_recently_modified_files(tmp_path):
p = tmp_path / "live.jsonl"
write_jsonl(p, [{"a": 1}])
assert list(R.iter_files([tmp_path], skip_active_seconds=60)) == []
assert len(list(R.iter_files([tmp_path], skip_active_seconds=0))) == 1
def test_discovery_skips_excluded_files(tmp_path):
write_jsonl(tmp_path / "ok.jsonl", [{"a": 1}])
(tmp_path / "auth.json").write_text("{}")
os.utime(tmp_path / "ok.jsonl", (0, 0))
os.utime(tmp_path / "auth.json", (0, 0))
names = {p.name for p in R.iter_files([tmp_path])}
assert names == {"ok.jsonl"}
# ------------------------------------------------------------------- reporting
def test_context_snippet_never_leaks_the_secret():
secret = "sk-ant-api03-" + fake(95, 1)
text = "before " * 10 + secret + " after" * 10
snip = R.context_snippet(text, secret, "anthropic-api-key", salt=b"s")
assert secret not in snip
assert "[REDACTED:anthropic-api-key:" in snip
def test_context_snippet_redacts_neighbouring_secrets():
"""Regression: the window around a match must be scrubbed too.
Secrets cluster. A shell profile dump puts an exported API key on the line above the
token, so centring on one match and printing raw context leaked the other one.
"""
target = "hf_" + fake(34, 13)
neighbour = "AIza" + fake(35, 9, string.ascii_letters + string.digits + "_-")
text = f"export GOOGLE_KEY={neighbour}\nexport HF_TOKEN={target}\n"
snip = R.context_snippet(text, target, "huggingface-token", salt=b"s", width=60)
assert target not in snip
assert neighbour not in snip, "a neighbouring secret leaked into the context window"
def test_cli_self_test_passes():
r = subprocess.run(
[sys.executable, str(Path(__file__).parent / "redact_agent_transcripts.py"), "--self-test"],
capture_output=True, text=True)
assert r.returncode == 0, r.stdout + r.stderr
def test_cli_exit_code_signals_findings(tmp_path):
secret = "ghp_" + fake(36, 4)
p = tmp_path / "s.jsonl"
write_jsonl(p, [{"v": secret}])
os.utime(p, (0, 0))
script = str(Path(__file__).parent / "redact_agent_transcripts.py")
r = subprocess.run([sys.executable, script, "--paths", str(tmp_path), "--json"],
capture_output=True, text=True)
assert r.returncode == 1
assert json.loads(r.stdout)["total_findings"] == 1
assert secret not in r.stdout + r.stderr
write_jsonl(p, [{"v": "clean"}])
os.utime(p, (0, 0))
r2 = subprocess.run([sys.executable, script, "--paths", str(tmp_path), "--json"],
capture_output=True, text=True)
assert r2.returncode == 0
# ------------------------------------------------- review round 2 regressions
def test_generic_detector_matches_keyword_with_identifier_tail():
"""`AWS_SECRET_ACCESS_KEY=` never matched: the keyword had to sit immediately
before the `=`, so `SECRET` + `_ACCESS_KEY` failed. This is the single most common
shape in a transcript, because it is what `env` and `cat .env` print."""
for name in ["AWS_SECRET_ACCESS_KEY", "SECRET_KEY", "DJANGO_SECRET_KEY",
"SECRET_KEY_BASE", "PRIVATE_KEY", "API_KEY_ID"]:
line = f"{name}={fake(40, 300)}"
assert R.find_secrets(line), f"missed {name}"
def test_longer_secret_is_redacted_before_its_own_prefix():
"""A masked display (`sk-ant-api03-abcd...`) next to the full key: replacing the
short prefix first left the remaining 70 characters of the real key on disk."""
full = "sk-ant-api03-" + fake(95, 1)
masked = full[:38]
raw = json.dumps({"a": "key " + full, "b": "masked " + masked + "..."})
out, found = R.redact_line(raw)
assert full not in out
assert full[38:] not in out, "the tail of the full key survived"
def test_semantic_invariant_holds_for_every_redacted_line():
full = "ghp_" + fake(36, 4)
raw = json.dumps({"a": full, "b": full[:20], "c": "x " + full})
out, _ = R.redact_line(raw, salt=b"s")
assert json.loads(out) == R._redact_obj(json.loads(raw), b"s", None)
def test_lone_surrogates_do_not_corrupt_or_abort(tmp_path):
"""The re-serialize fallback emitted raw surrogates: undecodable bytes on disk for a
low surrogate, and an uncaught UnicodeEncodeError killing the whole run for a high
one. JSON.stringify emits `\\ud83d` for a truncated emoji, so this is reachable."""
for esc in ["\\udc80", "\\ud83d"]:
p = tmp_path / f"s{esc[-2:]}.jsonl"
pw = "p\\u00e4ssw\\u00f6rd" + fake(12, 301)
p.write_text('{"v": "postgres://user:' + pw + '@h/x", "t": "lone ' + esc + ' here"}\n',
encoding="utf-8")
os.utime(p, (0, 0))
stats = R.redact_file(p, apply=True, backup_dir=tmp_path / "bk")
assert stats.error == "", f"run aborted on {esc}: {stats.error}"
p.read_bytes().decode("utf-8") # must not raise
def test_prefilter_matches_secrets_after_an_escaped_newline():
"""In a raw JSONL line `\\n` is two characters, so `n` before `AKIA` is not a word
boundary and the \\b-anchored prefilter rejected the line before decoding it."""
for secret in ["AKIA" + fake(16, 8, string.ascii_uppercase + string.digits),
"eyJ" + fake(30, 17) + "." + fake(40, 18) + "." + fake(43, 19)]:
for sep in ["\n", "\t", "\r\n"]:
raw = json.dumps({"c": f"out:{sep}{secret}"})
out, found = R.redact_line(raw)
assert found, f"prefilter missed {secret[:6]} after {sep!r}"
assert secret not in out
def test_prefilter_is_a_superset_of_every_detector():
for kind, secret in token_samples():
assert R._PREFILTER.search(json.dumps(secret)[1:-1]), f"prefilter misses {kind}"
def test_passwords_with_punctuation_are_detected():
for line in ["DB_PASSWORD=S3cr3t!Pass#2024xyz",
"DB_PASSWORD=S3cr3t$Pass%2024xyz",
"postgres://u:p%40ssW0rd12345678@h/db"]:
assert R.find_secrets(line), f"missed real password in: {line}"
def test_query_string_token_stops_at_the_ampersand():
tok = fake(32, 302)
found = R.find_secrets(f"https://api.x.com/v1?token={tok}&format=json")
assert found and found[0].value == tok, f"captured {found[0].value if found else None!r}"
def test_additional_credential_shapes():
assert R.find_secrets("Authorization: Basic " + fake(40, 303)), "basic auth"
assert R.find_secrets("redis://:" + fake(20, 304) + "@host:6379/0"), "empty-user url"
assert R.find_secrets("postgres://app:" + fake(10, 305) + "@db/x"), "short url password"
assert R.find_secrets("-----BEGIN PGP PRIVATE KEY BLOCK-----\n" + fake(64, 306)
+ "\n-----END PGP PRIVATE KEY BLOCK-----"), "pgp block"
assert R.find_secrets("-----BEGIN RSA PRIVATE KEY-----\n"
+ "\n".join(fake(64, 307 + i) for i in range(4))), "truncated pem"
assert R.find_secrets("sk-" + fake(48, 308)), "old-style openai key"
def test_write_failure_is_reported_not_raised(tmp_path):
secret = "ghp_" + fake(36, 4)
d = tmp_path / "ro"
d.mkdir()
p = d / "s.jsonl"
write_jsonl(p, [{"v": secret}])
os.utime(p, (0, 0))
os.chmod(d, 0o555)
try:
stats = R.redact_file(p, apply=True, backup_dir=tmp_path / "bk")
assert stats.error, "a read-only parent dir must set stats.error, not raise"
assert not list(d.glob(".redact-*")), "left a temp file behind"
finally:
os.chmod(d, 0o755)
def test_hardlinked_file_leaves_no_secret_behind(tmp_path):
secret = "ghp_" + fake(36, 4)
a = tmp_path / "a.jsonl"
write_jsonl(a, [{"v": secret}])
b = tmp_path / "b.jsonl"
os.link(a, b)
os.utime(a, (0, 0))
R.redact_file(a, apply=True, backup_dir=tmp_path / "bk")
assert secret not in b.read_text(), "the sibling hardlink kept the secret"
def test_pagination_tokens_are_not_credentials():
"""Google APIs return nextPageToken on every list call. It matched the `token`
keyword and was the single largest false-positive class in a real scan."""
for line in [
"nextPageToken: CAUQAA18ROMbGjBpZDo3NTg0NjAyMzc0NDU4NTI1OTE0",
"pageToken: ChUKEwjIwtvA9rGKAxUqxTgGHRVPDkM",
"continuationToken: eyIxIjoiMjAyNi0wOS0wMlQxMDowMDowMFoifQ",
" token_count: 184523 total_tokens_used: 9482716",
]:
assert R.find_secrets(line) == [], f"false positive on pagination: {line[:40]}"
def test_hashes_and_digests_are_not_credentials():
for line in [
"oauth2ClientSecretSha256: 4f8a91c2e7b3d5064a1f8e2c9b7d3a5f61e8c4b2",
"secret_hash: 9c1e4a7b2f8d3056e1a4c7b9d2f5083a6e1c4b7d",
"token_fingerprint: ab12cd34ef56ab78cd90ef12ab34cd56ef78ab90",
]:
assert R.find_secrets(line) == [], f"false positive on digest: {line[:40]}"
def test_value_must_be_on_the_same_line_as_the_keyword():
"""`secrets:` at the end of a YAML line, or in grep output, was matching the whole
of the FOLLOWING line as the credential."""
for line in [
"docker-compose.yml:204: secrets:\n - google_credentials",
"migration.md:42: if secret:\n return secret_value_here",
"config.yaml: api_key:\n # set this in production",
]:
assert R.find_secrets(line) == [], f"crossed a newline: {line[:40]!r}"
def test_same_line_assignments_still_match():
assert R.find_secrets("CLIENT_SECRET=" + fake(32, 400))
assert R.find_secrets(' "api_key": "' + fake(32, 401) + '"')
assert R.find_secrets("ZAI_API_KEY=" + fake(40, 402))
def test_function_calls_are_not_credentials():
"""A minified identifier can clear length, digit and entropy checks all at once
(`nonEmptyString$6`). What gives it away is the "(" straight after it."""
for line in [
"const envApiKey = nonEmptyString$6(process.env[apiKeyEnv]);",
" api_key = resolveCredential7Provider(config, env);",
"self.token = build_token_from_env0(request)",
]:
assert R.find_secrets(line) == [], f"false positive on a call: {line[:44]}"
def test_value_followed_by_other_punctuation_still_matches():
assert R.find_secrets("API_KEY=" + fake(30, 500) + ".")
assert R.find_secrets("API_KEY=" + fake(30, 501) + "}")
def test_unicode_line_separator_inside_a_string_is_not_a_line_break(tmp_path):
"""Python's splitlines() breaks on U+2028/U+2029/U+0085, which are legal inside a
JSON string. Splitting there turned one valid JSONL record into several invalid
fragments, which then took the raw-text fallback instead of structured redaction."""
secret = "ghp_" + fake(36, 4)
p = tmp_path / "s.jsonl"
p.write_text(json.dumps({"text": "before
after", "v": f"TOKEN={secret}"}) + "\n",
encoding="utf-8")
os.utime(p, (0, 0))
stats = R.redact_file(p, apply=True, backup_dir=tmp_path / "bk")
out = p.read_text(encoding="utf-8")
assert stats.findings == 1
assert secret not in out
for line in out.split("\n"):
if line.strip():
json.loads(line) # every real line must still parse
assert json.loads(out.strip())["text"] == "before
after", "separator not preserved"
def test_fallback_path_is_idempotent():
"""The re-serialize fallback emits ensure_ascii=True output whose bytes differ from
the original encoder's. A second pass must find nothing and change nothing."""
secret = "aB3d/Ef7gH" + fake(20, 40)
raw = '{"v":"API_KEY=' + secret.replace("/", "\\/") + '"}'
once, f1 = R.redact_line(raw)
twice, f2 = R.redact_line(once)
assert f1 and not f2, "second pass still reports findings"
assert once == twice, "second pass changed the bytes again"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment