
Donate Trace
- 26 installs
- 10 repo stars
- Updated June 16, 2026
- trace-commons-ai/donate-trace
Helps with ai & agent building tasks.
About
donate-trace is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- donate-trace
- AI & Agent Building
- AI-coding skill
Donate Trace by the numbers
- 26 all-time installs (skills.sh)
- Ranked #9,702 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/trace-commons-ai/donate-trace --skill donate-traceAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 26 |
|---|---|
| repo stars | ★ 10 |
| Last updated | June 16, 2026 |
| Repository | trace-commons-ai/donate-trace ↗ |
What it does
Helps with ai & agent building tasks.
Files
Donate a trace to Trace Commons
Trace Commons is one open, public dataset of coding-agent sessions that anyone can train on or study. This skill takes a single session from the current agent, removes anything sensitive on the user's own machine, and submits the cleaned result as a pull request to the dataset.
The whole point is that contributing is safe and the user stays in control: nothing leaves the machine until the user has seen what will be sent and confirmed it. Treat that promise as the core of the job.
Hard rules (read first)
These are not negotiable. The dataset is public and permanent, so a mistake here is published immediately.
1. The contributor certifies it's theirs to publish — you inform, they decide. By choosing to donate, the contributor certifies that this session is not private or confidential and that they have the right to publish it publicly under CC-BY-4.0. Your job is to make that an informed certification, not to gatekeep: surface what you can actually see — a private-looking git remote, missing license, client/employer-looking code — so they decide with eyes open. Don't silently upload something that looks private; name what you see and let the contributor make the call. The responsibility for publishability and licensing rests with the contributor, not with you or the dataset. (Secret/PII scrubbing in the next steps is separate and still mandatory — see rule 2.) 2. Local cleaning before upload. All anonymization happens on this machine, before anything is sent. Never upload a raw session. 3. User reviews before send. Always show the user a summary of what was removed and ask for explicit confirmation. No silent uploads. 4. One session only. Donate the single session the user chose, not their whole history. 5. When unsure about secrets, redact; when unsure about publishability, ask the contributor. If you're unsure whether a field is a secret or personal data, redact it — dropping signal is acceptable, leaking is not. If you're unsure whether the project is public or theirs to share, surface it and let the contributor certify; that call is theirs, not yours.
The flow
Follow these steps in order.
Step 1 — Surface the source, let the contributor certify
Before touching any logs, look at where the session came from and surface anything that bears on whether it's the contributor's to publish — so their consent later is informed. You are not the gatekeeper; the contributor certifies. You just make sure they're not certifying blind.
- If you can see the working directory, check the remote and license:
git -C <dir> remote get-url origin(andgit -C <dir> config --get remote.origin.url), and look for aLICENSE/LICENCE/COPYINGfile or an SPDX/licensefield in the manifest. - Tell the contributor what you found, plainly, e.g.: "This is a public repo licensed MIT" / "This repo is public but has no license (all-rights-reserved by default)" / "I can't find a public remote — this looks like a local or private project." Don't refuse on your own; surface it.
- The one thing you don't do is silently upload something that looks private or like employer/client code. Name it and let them decide. If they confirm it's theirs to publish, that's their certified call.
- Then move to cleaning. The explicit publishability certification happens at the confirmation step (Step 5).
Step 2 — Find the session
Identify which agent (harness) this is and locate the session file. Each agent stores sessions differently. Read the matching reference file for exact paths and formats:
- Claude Code →
references/claude-code.md - Codex →
references/codex.md - pi →
references/pi.md - opencode →
references/opencode.md - Cursor →
references/cursor.md
If you're not sure which agent you're running in, ask the user, or infer it from which session directory exists on disk. Default to the most recently modified session unless the user names a specific one.
Copy the session to a working location (e.g. /tmp/donate-trace/) so you never modify the user's real logs.
Step 3 — Clean it (deterministic pass first)
Run the bundled scrubber on the copied file. It removes the highest-confidence leaks deterministically — home-directory paths and usernames, common secret formats (API keys, tokens, PEM blocks, JWTs, KEY=value env assignments in shell commands), and emails. Doing these in code rather than by eye is deliberate: these patterns have crisp signatures and a missed credential is the worst outcome.
python scripts/scrub.py --in /tmp/donate-trace/<session-file> --harness <harness> --out /tmp/donate-trace/cleaned.jsonl --report /tmp/donate-trace/report.jsonThe scrubber writes the cleaned session and a JSON report listing every redaction it made.
Step 3.5 — Deep secret scan (if available)
Run the optional deep scan over the cleaned file:
python scripts/scan.py --in /tmp/donate-trace/cleaned.jsonl --report /tmp/donate-trace/scan.jsonThis wraps TruffleHog (hundreds of maintained detectors) for breadth beyond the scrubber's pattern list. Read the STATUS: line:
- `trufflehog_not_installed` — don't block and don't auto-install. Note it for the Step 5 summary so the user knows the deep scan didn't run locally. (The anonymous donation path runs it server-side; the attributed path does not, so for attributed donations either suggest installing TruffleHog with the one-liner the script prints, or proceed knowing only the pattern pass ran.)
- `findings` — treat each as a must-confirm item in the review pass below: it may be a real secret the scrubber missed, or a false positive on a high-entropy string (hash, ID, base64). Resolve each with the user before uploading. These do not auto-block — TruffleHog runs without verification, so judgment is required.
- `clean` / `scan_error` — proceed; mention a scan error in the summary if it occurred.
Step 4 — Review pass (your judgment)
The scrubber catches patterns; you catch meaning. Read the cleaned session and look for things a regex can't recognize:
- Personal names, company names, or client names in prose (prompts, commit messages, comments).
- Internal hostnames, project codenames, ticket IDs, or URLs that identify a person or org.
- Anything in free text that a stranger could use to identify who wrote this or where they work.
Redact anything you find by replacing it with a neutral placeholder (e.g. [NAME], [COMPANY], [INTERNAL_URL]). Note what you changed so it can go in the summary. When unsure, redact.
See references/anonymization.md for the full field-by-field guide to where sensitive data hides in each harness.
Step 5 — Show the user, get confirmation
Summarize plainly what will be donated and what was removed. Keep it human:
Ready to donate this session to Trace Commons.
Removed:
- 4 home-directory paths (your username)
- 1 API key in a shell command
- 2 email addresses
- 1 company name in a commit message ("Acme")
Deep scan: TruffleHog clean. (or: "not installed — pattern pass only";
or: "flagged 1 'Box' match, you confirmed it's a hash, not a secret")
The cleaned session has 35 messages and 12 tool calls. Nothing has been
uploaded yet.
By saying yes, you're certifying this session isn't private or confidential
and that you have the right to publish it publicly under CC-BY-4.0. Open the
pull request?Always include the Deep scan line so the user knows whether the deep scan actually ran — never let its absence be silent, especially for attributed donations, which get no server-side backstop.
The confirmation is the contributor's certification that the content is theirs to publish — make that explicit (as above) and wait for an explicit yes. The responsibility for publishability rests with them; your job was to make the decision informed and to scrub secrets/PII, not to withhold the donation. If the user wants to see the full cleaned file, show it. If they want to pull something else out, do it and re-summarize.
Step 6 — Submit
Only after confirmation. There are two ways to submit, and the skill picks one automatically but lets the user choose.
First, detect whether this machine has a Hugging Face login. The current CLI is hf; older machines have the deprecated huggingface-cli. Use whichever exists:
HF_CLI=$(command -v hf || command -v huggingface-cli)
"$HF_CLI" auth whoami 2>/dev/null || "$HF_CLI" whoami 2>/dev/null(hf auth whoami is the current form; the legacy CLI uses huggingface-cli whoami.)
- If it succeeds (prints a username): default to the attributed path. The donation becomes a pull request opened by the user's own account. Tell them: "You're logged in to Hugging Face as
<name>, so I'll open the pull request under your account. Prefer to donate anonymously instead?" - If it fails (not logged in): default to the anonymous path. The donation is sent through the Trace Commons server, which opens the pull request on your behalf under a project account. No Hugging Face account needed. Tell them: "You're not logged in to Hugging Face, so I'll donate anonymously through the Trace Commons server. Prefer to attribute it to your own account? You'd need to run
hf auth login(orhuggingface-cli loginon older installs) first."
Always state which path you're taking and let the user switch. Some people want attribution; some specifically want anonymity even when logged in. Respect the override.
Then submit via the chosen path. Both paths open a pull request (never a direct push) so a maintainer reviews before anything goes public. See references/publishing.md for the exact commands for each path.
Then tell the user where to track it and thank them once, briefly.
If the user just wants to understand the skill
If they're asking what this does rather than asking to donate right now, explain it plainly and point them at the open-source-only rule. Don't start reading their logs unless they want to donate.
Anonymization guide
Two passes. The scrubber (scripts/scrub.py) does pass one deterministically. You do pass two with judgment. Together they cover the field.
Pass one — what the scrubber already handles
- Home-directory paths on Mac, Linux, and Windows → username replaced with
USER - Secret formats: AWS keys, GitHub/OpenAI/Anthropic/Slack/Google keys, JWTs, PEM private-key blocks, bearer tokens, database connection strings, and
KEY=valueenv assignments where the key name implies a secret - Email addresses
You do not need to redo these. Trust the report it produces.
Pass two — what you must check by reading
The scrubber recognizes patterns; it cannot recognize meaning. Read the cleaned session and look for:
- Personal names in prose — prompts, commit messages, code comments, review notes. Replace with
[NAME]. - Bare usernames / handles. The scrubber normalizes the username inside paths (
/Users/USER/), but the same handle still appears on its own elsewhere: the owner column ofls -loutput,whoami/idoutput, GitHub or Hugging Face URLs and account names (github.com/<handle>,user: <handle>), and prose. The scrubber cannot tell an arbitrary word is a username, so catch these by reading. Replace withUSER(or[NAME]in prose). - Company / client / customer names. Replace with
[COMPANY]. - Internal hostnames and URLs (
*.internal,*.local, private IPs, intranet links). Replace with[INTERNAL_URL]. - Project codenames and ticket IDs that identify a specific org (e.g.
JIRA-1234, internal project names). Replace with[REF]if identifying. - Anything else that could identify the author or their employer — physical addresses, phone numbers the scrubber missed, unusual usernames in prose.
Where to look per harness
- Claude Code:
message.content[].text, Bash toolinput.command, tool_result outputs - Codex:
payload.content[].text - pi:
message.content(string or block list) - opencode: message/part text fields in the exported JSON
- Cursor: message/prompt text and shell-command/tool-call payloads in the
agent-transcripts/*.jsonllines
The rule when unsure
Redact. The dataset is public and permanent. Dropping a bit of signal costs nothing; leaking a name or a key cannot be undone. If a whole session feels too sensitive to clean confidently, tell the user it may not be a good candidate to donate.
What NOT to over-redact
Don't gut the session of its value. Library names, public API endpoints, common shell commands, framework names, error messages, and ordinary code are the point of the dataset. Keep them. The goal is to remove who and where, not what was done.
Claude Code sessions
Location
~/.claude/projects/<project-slug>/<session-uuid>.jsonl
One JSONL file per session. The <project-slug> is derived from the working directory. Pick the most recently modified file unless the user names one.
Format
One JSON object per line. Top-level type is one of: user, assistant, queue-operation, last-prompt.
Identity / sensitive fields:
- top-level
cwd— absolute working directory (contains the username) - top-level
gitBranch— branch name (may encode a feature/client name) - top-level
sessionId, andmessage.id,message.content[].id(msg_*,toolu_*)
Free-text carriers (where prompts, commands, and outputs live):
message.content[].text— assistant/user prosemessage.content[].input— tool inputs; for the Bash tool this is.input.command, the single richest source of leaked secrets- tool_result content blocks — command output, which can contain anything
Donating
The native file is already in the right shape. After scrubbing, place the cleaned file at sessions/claude_code/<filename> in the dataset. The traces rows are just the parsed lines.
Codex sessions
Location
~/.codex/sessions/ — but sessions are sharded into dated subdirectories, not stored flat: ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<timestamp>-<uuid>.jsonl (also check ~/.codex/archived_sessions/). Verified against a real install with the ~/.codex/sessions/2025/11/20/rollout-…jsonl layout.
Find the latest with a recursive search rather than a flat ls, e.g. find ~/.codex/sessions -name 'rollout-*.jsonl' -print0 | xargs -0 ls -t | head -1.
One JSONL file per session. Pick the most recently modified unless the user names one.
Format
One JSON object per line. Top-level type is one of: response_item, event_msg, session_meta, turn_context. Almost everything is wrapped in a payload object.
Identity / sensitive fields:
payload.cwd— working directory (contains the username)payload.id— session id
Free-text carriers:
payload.content[].text— both input (input_text) and output (output_text) prose- any command/tool payloads under
payload— inspect for shell commands
Donating
Native file is the right shape. After scrubbing, place at sessions/codex/<filename>.
Cursor sessions
Cursor support is newer than the others — flag that to the user. The paths below come from Cursor's documented storage layout, not yet a verified live donation; sanity-check what's actually on disk before trusting them, and prefer the most recently modified file.
Location
The Cursor CLI agent (cursor-agent, often aliased agent) writes a JSONL transcript per session — this is the right shape to donate:
~/.cursor/projects/<project>/agent-transcripts/<id>.jsonl
Find the newest with a recursive search rather than a flat ls: find ~/.cursor/projects -path '*/agent-transcripts/*.jsonl' -print0 | xargs -0 ls -t | head -1
Cursor also keeps chat metadata in SQLite (~/.cursor/chats/*/*/store.db) and the desktop app stores composer/chat state inside ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb (Linux: ~/.config/Cursor/...). Do not try to read those by hand — donate the agent-transcripts/*.jsonl file, which is already one clean line-per-event document.
To list/resume sessions from the CLI: cursor-agent ls and cursor-agent --resume <id>.
Format
One JSON object per line (JSONL). The scrubber handles JSONL directly. Identity / free-text carriers to expect (the deterministic scrubber walks every string value regardless, so this is mainly for the Step 4 review pass):
- a working-directory / project-path field (contains the username) — redacted by the home-path rule
- prompt and assistant message text, and any shell-command / tool-call payloads — inspect for names, internal hostnames, secrets a regex can't catch
Donating
Native transcript file is the right shape. After scrubbing, place at sessions/cursor/<filename>.
opencode sessions
The difference
Unlike the other agents, opencode does not store a session as one file. Its storage tree (~/.local/share/opencode/storage/) splits a session across message/, part/, project/, and session_diff/ directories (the exact subdirs vary by version — on 1.1.x there is no top-level session/ dir). Do not try to read those by hand.
Use the export command instead
opencode can export one session as a single JSON document.
# find the session id (the --format json output includes id, title, directory)
opencode session list --format json
# session ids look like "ses_414f42d4effeu7crLsmVPtJhZd"
# export it (see the version note below for which form your CLI supports)
opencode export <sessionID> > /tmp/donate-trace/opencode-session.jsonCLI changed between versions — check what your `opencode export --help` accepts:
- Current opencode (verified on 1.1.4):
exporttakes only[sessionID]. There is no `-o`/`--output` and no `--format` flag — those are silently ignored and no file is written. You must capture stdout with>(oropencode export <id> -o filewill produce nothing). On this version stdout is clean valid JSON (first byte{, last}) — the historical status-line-prepend bug is gone. - Older opencode: some builds supported
--format json -o <file>and/or prepended a status line to stdout. If yourexport --helpshows-o/--output, prefer it.
Always validate after export, on every version: run the result through a JSON parser (python3 -c "import json,sys;json.load(open(sys.argv[1]))" <file>). If it won't parse — e.g. a leading status line from an old build — strip the offending line or tell the user and stop. Never scrub or donate a file that doesn't parse.
Format
opencode export produces opencode's own JSON document (a single object, not JSONL) containing the message history and metadata. The scrubber handles single-document JSON as well as JSONL.
Free-text carriers: message and part text fields within the exported document; working directory under the session metadata (directory / project path).
Donating
opencode's representation in the dataset is not a settled convention yet — it is something this project defines. For now: after scrubbing the exported JSON, place it at sessions/opencode/<sessionID>.json. Flag to the user that opencode support is newer than the others.
pi sessions
Location
~/.pi/agent/sessions/ — sessions are grouped into one subdirectory per project, named after the slugified working directory, not stored flat. The real layout is: ~/.pi/agent/sessions/<project-slug>/<timestamp>_<uuid>.jsonl (e.g. ~/.pi/agent/sessions/--Users-USER-ComparIA--/2026-04-29T15-34-56-687Z_<uuid>.jsonl). Verified against a real install. Filenames are <ISO-timestamp>Z_<uuid>.jsonl.
Recurse to find the latest, e.g. find ~/.pi/agent/sessions -name '*.jsonl' -print0 | xargs -0 ls -t | head -1.
One JSONL file per session. Pick the most recently modified unless the user names one.
Format
One JSON object per line. Top-level type is one of: message, session, model_change, thinking_level_change, compaction.
Identity / sensitive fields:
- top-level
cwd— working directory (contains the username) - top-level
id,version
Free-text carriers:
message.content— either a string or a list of blocks; when a list, each block may have.text- inspect message content for shell commands and tool output
Donating
Native file is the right shape. After scrubbing, place at sessions/pi/<filename>.
Note
pi invokes the skill as /skill:donate-trace rather than /donate-trace.
Publishing to Trace Commons
Where it goes
The dataset lives on the Hugging Face Hub. The cleaned session becomes one file in the dataset's folder tree:
sessions/<harness>/<filename>where <harness> is one of claude_code, codex, pi, opencode, cursor. The session file is uploaded raw and unmodified except for anonymization — never reshaped or wrapped — so the Hub recognizes it as a native agent trace and renders the session timeline. All harnesses share a single dataset table; the folder name records the harness, so there is no per-agent config. Both submission paths below produce a pull request, never a direct push, so a maintainer reviews before anything becomes public.
The dataset is `trace-commons/agent-traces` and the anonymous ingestion server is `https://trace-commons-web.hf.space` (the same Space that serves the website; the donation endpoint is /donate). The license is CC-BY-4.0 — make sure the user understands the result is public under that license.
CLI note. The current Hugging Face CLI is hf (shipped with recent huggingface_hub). Older machines have the deprecated huggingface-cli instead. The commands below show hf; if it isn't found, fall back to the same command with huggingface-cli (e.g. huggingface-cli whoami, huggingface-cli upload). Detect with command -v hf || command -v huggingface-cli.
---
Path A — attributed (user is logged in to Hugging Face)
The PR is opened under the user's own account. Use the CLI:
hf upload trace-commons/agent-traces \
/tmp/donate-trace/cleaned.jsonl \
sessions/<harness>/<filename> \
--repo-type dataset \
--create-prThe command prints the pull request URL. Give it to the user.
---
Path B — anonymous (no Hugging Face account)
The cleaned trace is sent to the Trace Commons server, which re-checks it and opens the PR under a project account. The user needs no Hugging Face login. The server is the same Space that hosts the website, at https://trace-commons-web.hf.space, so the donation endpoint is https://trace-commons-web.hf.space/donate.
curl -sS -X POST "https://trace-commons-web.hf.space/donate" \
-H "Content-Type: application/json" \
--data-binary @/tmp/donate-trace/payload.jsonBuild payload.json first so the trace is embedded safely as a JSON string:
python3 - <<'PY'
import json
trace = open('/tmp/donate-trace/cleaned.jsonl').read()
payload = {
"harness": "<harness>",
"filename": "<filename>",
"consent": True,
"trace": trace,
}
json.dump(payload, open('/tmp/donate-trace/payload.json', 'w'))
PYconsent: truerecords that the user agreed to publish under the dataset's open license. Only set it after the user has confirmed.- The server runs the same deterministic scrubber again as a backstop, then opens the PR. It returns JSON containing the pull request URL. Give that URL to the user.
- If the server rejects the submission (for example its backstop scrubber still finds a secret), it returns an error explaining what was found. Relay that to the user and do not retry blindly — clean the flagged item first.
---
Consent and license
By submitting through either path, the contributor agrees the cleaned trace is published under the dataset's open license. For Path A, the user's own account action is the record. For Path B, the consent flag is the record. Either way, make sure the user understands the result is public and openly licensed before you submit. This ties back to the open-source-only rule.
After submitting
Give the user the PR link so they can track it. Thank them once, briefly. Don't oversell or ask them to donate again.
#!/usr/bin/env python3
"""
scan.py — optional deep secret scan for Trace Commons donations.
The deterministic scrubber (scrub.py) is a fast, high-confidence first pass with
a hand-maintained pattern list. This wraps TruffleHog (hundreds of maintained
detectors) for breadth — the same scanner the ingestion server runs as a
backstop. It is deliberately OPTIONAL:
- If `trufflehog` is not installed, this prints a notice and exits cleanly.
Nothing is required; the donation can still proceed on the scrub.py pass
plus the human review. (The anonymous donation path also re-scans server
side; attributed donations do not, which is exactly why running this
locally is worthwhile.)
- If it is installed, findings are reported for the review pass to confirm.
They are NOT a hard block: TruffleHog runs WITHOUT verification (so no
candidate secret is ever sent to a third party), which means it can
false-positive on high-entropy strings (hashes, IDs, base64). Treat each
finding as "confirm this isn't a real secret before uploading."
This mirrors the server's behaviour and flags; keep the two in sync.
Usage:
python scan.py --in cleaned.jsonl [--report report.json]
Exit code is always 0 — this is advisory, not a gate. Read the STATUS line.
"""
import json
import shutil
import argparse
import subprocess
INSTALL_HINT = (
"Install once (single static binary, no toolchain):\n"
" curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh "
"| sh -s -- -b ~/.local/bin"
)
def scan(path):
"""Return (status, findings).
status is one of: 'not_installed', 'clean', 'findings', 'error'.
findings is a list of {detector, line, preview} dicts (empty unless 'findings').
"""
if not shutil.which("trufflehog"):
return "not_installed", []
try:
proc = subprocess.run(
["trufflehog", "filesystem", path,
"--json", "--no-verification", "--no-update"],
capture_output=True, text=True, timeout=180,
)
except (subprocess.TimeoutExpired, OSError):
return "error", []
findings = []
for line in proc.stdout.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
detector = obj.get("DetectorName") or obj.get("DetectorType")
if not detector:
continue
raw = obj.get("Raw") or obj.get("RawV2") or ""
preview = (raw[:4] + "…" + raw[-3:]) if len(raw) > 9 else "***"
loc = (
obj.get("SourceMetadata", {})
.get("Data", {})
.get("Filesystem", {})
.get("line")
)
findings.append({"detector": str(detector), "line": loc, "preview": preview})
return ("findings" if findings else "clean"), findings
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--in", dest="inp", required=True)
ap.add_argument("--report", default=None)
args = ap.parse_args()
status, findings = scan(args.inp)
if status == "not_installed":
print("STATUS: trufflehog_not_installed")
print("Deep scan skipped — TruffleHog is not installed, so this donation")
print("was checked by the pattern-based pass (scrub.py) only.")
print("Anonymous donations are deep-scanned server-side; attributed ones")
print("are not, so installing TruffleHog is worthwhile for attributed PRs.")
print(INSTALL_HINT)
elif status == "error":
print("STATUS: scan_error")
print("TruffleHog is installed but the scan did not complete (timeout or")
print("execution error). Proceed on the scrub.py pass + review, or retry.")
elif status == "clean":
print("STATUS: clean")
print("TruffleHog found no secrets in the cleaned trace.")
else: # findings
detectors = sorted({f["detector"] for f in findings})
print(f"STATUS: findings ({len(findings)})")
print("Detectors: " + ", ".join(detectors))
print("These ran WITHOUT verification and are often false positives on")
print("high-entropy strings. Confirm each is NOT a real secret before upload:")
for f in findings:
loc = f"line {f['line']}" if f["line"] else "location unknown"
print(f" - {f['detector']}: {f['preview']} ({loc})")
if args.report:
with open(args.report, "w", encoding="utf-8") as f:
json.dump({"status": status, "findings": findings}, f, indent=2)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
scrub.py — deterministic anonymization pass for Trace Commons donations.
Removes the high-confidence, crisply-patterned leaks from a coding-agent
session before it is reviewed and donated:
- home-directory paths and the username embedded in them
- common secret formats (API keys, tokens, PEM blocks, JWTs, env assignments)
- email addresses
This is intentionally NOT the whole anonymization story, and the secret list
here is a fast first pass, not the authoritative one. Three layers back it up:
the ingestion server re-runs TruffleHog (hundreds of maintained, updated secret
detectors) and rejects anything it flags; the skill performs an LLM/human review
pass for fuzzy things a regex can't recognize (personal names in prose, company
names, internal codenames); and the contributor reviews the exact diff before
anything is uploaded. The split is deliberate: code handles the patterns that
have signatures, a dedicated scanner handles breadth, and a human handles meaning.
The script walks the parsed JSON of each session line and rewrites string
values in place, so it works regardless of where in the structure a string
sits. It writes a cleaned file plus a JSON report of every redaction.
Usage:
python scrub.py --in session.jsonl --harness claude_code \
--out cleaned.jsonl --report report.json
"""
import argparse
import json
import re
import sys
from collections import Counter
# --- redaction patterns -----------------------------------------------------
# Order matters: more specific patterns run before more general ones.
HOME_PATH = re.compile(r'(\\?/(?:Users|home))\\?/([^/\\\s"\']+)')
# Dash-encoded home paths. Coding agents (e.g. Claude Code) name their project
# directories by replacing the slashes of an absolute path with dashes, so
# /Users/<name>/proj becomes the slug .claude/projects/-Users-<name>-proj. The
# slash-based HOME_PATH never sees these, so the username leaks. Anchored on the
# leading "/-Users-" / "/-home-" of the slug to avoid mangling hyphenated prose.
HOME_PATH_DASH = re.compile(r'(/-(?:Users|home))-([^-\s"\'\\/]+)')
# Windows user paths too
WIN_PATH = re.compile(r'([A-Za-z]:\\Users\\)([^\\\s"\']+)', re.IGNORECASE)
EMAIL = re.compile(r'\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b')
# RFC1918 private / internal IPv4 addresses. Not a secret, but it leaks internal
# network topology (DB hosts, service IPs), so it is redacted like home paths —
# without causing the server backstop to reject the whole donation. The four-octet
# shape with a fixed private prefix avoids mangling version numbers like 1.2.3.4.
PRIVATE_IP = re.compile(
r'\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}'
r'|192\.168\.\d{1,3}\.\d{1,3}'
r'|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}'
r'|169\.254\.\d{1,3}\.\d{1,3})\b'
)
# Secrets — each tuple is (name, compiled regex). Keep these conservative
# enough to avoid mangling ordinary prose but broad enough to catch real keys.
SECRET_PATTERNS = [
("aws_access_key", re.compile(r'\bAKIA[0-9A-Z]{16}\b')),
("aws_secret", re.compile(r'\b(?i:aws_secret_access_key)\s*[=:]\s*["\']?[A-Za-z0-9/+=]{40}["\']?')),
("github_token", re.compile(r'\bgh[pousr]_[A-Za-z0-9]{36,}\b')),
("hf_token", re.compile(r'\bhf_[A-Za-z0-9]{30,}\b')),
("openai_key", re.compile(r'\bsk-[A-Za-z0-9_\-]{20,}\b')),
("anthropic_key", re.compile(r'\bsk-ant-[A-Za-z0-9_\-]{20,}\b')),
("slack_token", re.compile(r'\bxox[baprs]-[A-Za-z0-9\-]{10,}\b')),
("google_api_key", re.compile(r'\bAIza[0-9A-Za-z_\-]{35}\b')),
("jwt", re.compile(r'\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\b')),
("private_key_block", re.compile(r'-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----.*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----', re.DOTALL)),
("bearer_token", re.compile(r'\b(?i:bearer)\s+[A-Za-z0-9_\-\.=]{20,}')),
("connection_string", re.compile(r'\b(?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis|amqp)://[^\s"\'<>]+:[^\s"\'<>@]+@[^\s"\'<>]+')),
# More vendor-prefixed tokens. This list is necessarily incomplete — it is a
# fast first pass, NOT the authoritative check. The ingestion server runs
# TruffleHog (hundreds of maintained detectors) as the real backstop and
# rejects anything it flags. Keep these prefix-anchored to avoid false hits.
("github_fine_grained_pat", re.compile(r'\bgithub_pat_[0-9A-Za-z_]{22,}\b')),
("gitlab_pat", re.compile(r'\bglpat-[0-9A-Za-z_\-]{20,}\b')),
("gcp_oauth_token", re.compile(r'\bya29\.[0-9A-Za-z_\-]{20,}\b')),
("stripe_key", re.compile(r'\b(?:sk|rk)_(?:live|test)_[0-9A-Za-z]{20,}\b')),
("sendgrid_key", re.compile(r'\bSG\.[A-Za-z0-9_\-]{16,32}\.[A-Za-z0-9_\-]{16,64}\b')),
("npm_token", re.compile(r'\bnpm_[0-9A-Za-z]{36}\b')),
("pypi_token", re.compile(r'\bpypi-[A-Za-z0-9_\-]{16,}\b')),
# Twilio (SK + 32 hex) is deliberately NOT regexed here: the shape collides
# with ordinary hashes/IDs and would cause false redactions. TruffleHog's
# validated Twilio detector handles it on the server backstop instead.
("azure_storage_key", re.compile(r'\bAccountKey=[A-Za-z0-9+/=]{40,}')),
("slack_webhook", re.compile(r'https://hooks\.slack\.com/services/[A-Za-z0-9/_\-]+')),
("discord_webhook", re.compile(r'https://(?:canary\.|ptb\.)?discord(?:app)?\.com/api/webhooks/[0-9]+/[A-Za-z0-9_\-]+')),
# generic KEY=secret env assignments where the value looks secret-ish
("env_secret", re.compile(r'\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|CREDENTIAL|API)[A-Z0-9_]*)\s*=\s*["\']?([^\s"\']{8,})["\']?')),
]
def redact_string(s, counts):
"""Apply all redactions to a single string, tallying what was changed."""
if not isinstance(s, str) or not s:
return s
# Secrets first (before paths/emails, since some secrets contain those shapes)
for name, pat in SECRET_PATTERNS:
def _sub(m, _name=name):
counts[_name] += 1
if _name == "env_secret":
# keep the key name, redact the value
return f"{m.group(1)}=[REDACTED_SECRET]"
return "[REDACTED_SECRET]"
s = pat.sub(_sub, s)
# Home paths -> normalize the username segment
def _home(m):
counts["home_path"] += 1
return f"{m.group(1)}/USER"
s = HOME_PATH.sub(_home, s)
def _home_dash(m):
counts["home_path"] += 1
return f"{m.group(1)}-USER"
s = HOME_PATH_DASH.sub(_home_dash, s)
def _win(m):
counts["home_path"] += 1
return f"{m.group(1)}USER"
s = WIN_PATH.sub(_win, s)
# Emails
def _email(m):
counts["email"] += 1
return "[REDACTED_EMAIL]"
s = EMAIL.sub(_email, s)
# Private/internal IPs (redact-only, not treated as a rejectable secret)
def _ip(m):
counts["private_ip"] += 1
return "[REDACTED_IP]"
s = PRIVATE_IP.sub(_ip, s)
return s
def walk(obj, counts):
"""Recursively rewrite all string values in a parsed JSON structure."""
if isinstance(obj, str):
return redact_string(obj, counts)
if isinstance(obj, list):
return [walk(x, counts) for x in obj]
if isinstance(obj, dict):
# Keys can carry leaks too — some agents key objects by absolute file
# path (e.g. {"/Users/<name>/proj/file": ...}), so scrub keys as well.
return {
(redact_string(k, counts) if isinstance(k, str) else k): walk(v, counts)
for k, v in obj.items()
}
return obj
def scrub_text(raw, harness):
"""Scrub a raw session string. Returns (cleaned_text, report_dict).
Importable so the server can run the exact same detection as the skill,
as a backstop. Mirrors the file-based main() below.
"""
counts = Counter()
lines_in = 0
lines_out = []
stripped = raw.strip()
is_single_doc = stripped.startswith("{") and stripped.count("\n") > 0 and not _looks_like_jsonl(stripped)
if is_single_doc:
try:
doc = json.loads(stripped)
cleaned = walk(doc, counts)
lines_out.append(json.dumps(cleaned, ensure_ascii=False))
lines_in = 1
except json.JSONDecodeError:
is_single_doc = False
if not is_single_doc:
for line in raw.splitlines():
line = line.strip()
if not line:
continue
lines_in += 1
try:
obj = json.loads(line)
except json.JSONDecodeError:
lines_out.append(redact_string(line, counts))
continue
cleaned = walk(obj, counts)
lines_out.append(json.dumps(cleaned, ensure_ascii=False))
report = {
"harness": harness,
"lines_processed": lines_in,
"redactions": dict(counts),
"total_redactions": sum(counts.values()),
}
return "\n".join(lines_out) + "\n", report
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--in", dest="inp", required=True)
ap.add_argument("--harness", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--report", required=True)
args = ap.parse_args()
with open(args.inp, "r", encoding="utf-8", errors="replace") as f:
raw = f.read()
cleaned_text, report = scrub_text(raw, args.harness)
counts = Counter(report["redactions"])
lines_in = report["lines_processed"]
with open(args.out, "w", encoding="utf-8") as f:
f.write(cleaned_text)
with open(args.report, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2)
# Human-readable summary to stdout for the skill to relay
print(f"Scrubbed {lines_in} lines from {args.harness} session.")
if counts:
for k, v in counts.most_common():
print(f" {v}× {k}")
else:
print(" No high-confidence secrets or paths found by the automated pass.")
print(f"\nCleaned file: {args.out}")
print(f"Report: {args.report}")
print("\nThis is the automated pass only. Now do the review pass for names,")
print("company names, and internal references before showing the user.")
def _looks_like_jsonl(text):
"""Heuristic: if the first two non-empty lines each parse as JSON, it's JSONL."""
parsed = 0
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
json.loads(line)
parsed += 1
except json.JSONDecodeError:
return False
if parsed >= 2:
return True
return False
if __name__ == "__main__":
main()