Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/opc-lab.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
pull_request:
paths:
- 'labs/opc-company/**'
- 'labs/chat-export/**'
- 'scripts/verify.sh'
- 'README.md'
- 'README.en.md'
Expand Down
2 changes: 1 addition & 1 deletion labs/chat-export/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ rg -l "上次那个部署问题" ~/.local/share/chat-export

丢:系统提示和注入的上下文(`<system-reminder>`、整份 AGENTS.md、环境信息)、思考过程、工具调用与结果、子 Agent 的旁支对话。不丢的话,每个会话都带一份 AGENTS.md,任何关键词一搜就命中全部会话。

导出前会把常见凭证替换成 `<REDACTED_…>`:私钥、`sk-` 开头的 API key、GitHub / AWS / Slack token、Bearer 头、`*_TOKEN=` 这类赋值、超长 base64。这是尽力而为的规则匹配,**不能保证把所有敏感内容都清掉**。导出的目录和文件都只有你本人能读写(0700 / 0600),只适合留在本机,不要提交到仓库,也不要上传。
导出前会把常见凭证替换成 `<REDACTED_…>`:私钥(包括完整 BEGIN/END 标记的多行 PEM 私钥)、`sk-` 开头的 API key、GitHub / AWS / Slack token、Bearer 头、`*_TOKEN=` 这类赋值、超长 base64。这是尽力而为的规则匹配,**不能保证把所有敏感内容都清掉**。导出的目录和文件都只有你本人能读写(0700 / 0600),只适合留在本机,不要提交到仓库,也不要上传。截断/非标准编码的密钥和标题、路径等元数据未被全面覆盖,不能将输出视为可公开分享的安全脱敏版本。

## 踩过的坑

Expand Down
7 changes: 7 additions & 0 deletions labs/chat-export/scripts/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@

# Credentials are redacted before anything touches disk. Most specific patterns first.
REDACTIONS = [
# Complete multi-line PEM private keys (RSA, EC, OpenSSH, PKCS#8, encrypted).
# Stop at another BEGIN marker to avoid eating unrelated chat text.
(re.compile(
r"-----BEGIN (?P<kind>(?:[A-Z0-9-]+ )*PRIVATE KEY)-----"
r"(?:(?!-----BEGIN (?:[A-Z0-9-]+ )*PRIVATE KEY-----)[\s\S])*?"
r"-----END (?P=kind)-----"
), "<REDACTED_PRIVKEY>"),
(re.compile(r"0x[a-fA-F0-9]{64}\b"), "<REDACTED_PRIVKEY>"),
(re.compile(r"\bsk-[A-Za-z0-9_\-]{20,}"), "<REDACTED_APIKEY>"),
(re.compile(r"\bghp_[A-Za-z0-9]{20,}"), "<REDACTED_GHTOKEN>"),
Expand Down
54 changes: 51 additions & 3 deletions labs/chat-export/tests/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
import common # noqa: E402
import export_claude # noqa: E402
import export_codex # noqa: E402
import export_cursor # noqa: E402
Expand Down Expand Up @@ -39,6 +40,49 @@ def jsonl(path, records):
path.write_text("\n".join(json.dumps(r, ensure_ascii=False) for r in records) + "\n")



def fake_pem(kind, newline="\n"):
"""Not a real key: tiny base64-encoded plain text, assembled at runtime."""
return newline.join((
"-----" + "BEGIN " + kind + "-----",
"QUJDREVGRw==",
"SElKS0w=",
"-----" + "END " + kind + "-----",
))


def test_redaction(tmp):
# All private-key labels use complete BEGIN/END delimiters and short PEM lines.
for kind in ("PRIVATE KEY", "RSA PRIVATE KEY", "EC PRIVATE KEY",
"DSA PRIVATE KEY", "OPENSSH PRIVATE KEY", "ENCRYPTED PRIVATE KEY"):
text = "Intro\n" + fake_pem(kind) + "\nOutro"
expect(common.redact(text) == "Intro\n<REDACTED_PRIVKEY>\nOutro",
f"PEM {kind}: redact only the key block, retain surrounding text")

crlf = "Before\r\n" + fake_pem("ENCRYPTED PRIVATE KEY", "\r\n") + "\r\nAfter"
expect(common.redact(crlf) == "Before\r\n<REDACTED_PRIVKEY>\r\nAfter",
"PEM: CRLF-encoded key must be redacted")

separate = fake_pem("RSA PRIVATE KEY") + "\nkeep between\n" + fake_pem("RSA PRIVATE KEY")
expect(common.redact(separate) == "<REDACTED_PRIVKEY>\nkeep between\n<REDACTED_PRIVKEY>",
"PEM: separately redact adjacent keys without consuming other messages")

orphan = "-----" + "BEGIN RSA PRIVATE KEY-----\nexample only\n"
expect(common.redact(orphan + "keep\n" + fake_pem("RSA PRIVATE KEY")) ==
orphan + "keep\n<REDACTED_PRIVKEY>",
"PEM: unpaired example must not swallow a later complete key")

ordinary = ("普通聊天不应消失。\n多行说明继续保留。\n"
"-----BEGIN PUBLIC KEY-----\nQUJDREVGRw==\n-----END PUBLIC KEY-----")
expect(common.redact(ordinary) == ordinary,
"normal multiline text and public keys must be preserved")

api = KEY + "\n" + "ghp_" + "b" * 30 + "\nAPI_KEY=fictional-value"
expect(common.redact(api) ==
"<REDACTED_APIKEY>\n<REDACTED_GHTOKEN>\nAPI_KEY=<REDACTED>",
"existing API-key, GitHub-token and assignment redaction must remain")


def test_claude(tmp):
jsonl(tmp / "src/proj/s1.jsonl", [
{"type": "user", "sessionId": "s1", "timestamp": "2026-10-01T00:00:00Z", "cwd": "/w",
Expand All @@ -47,11 +91,15 @@ def test_claude(tmp):
{"type": "text", "text": f"Use the key {KEY}"}]}},
{"type": "user", "message": {"content": [{"type": "tool_result", "content": "tool noise"}]}},
{"type": "assistant", "isSidechain": True, "message": {"content": [{"type": "text", "text": "subagent"}]}},
{"type": "assistant", "message": {"content": [{"type": "text", "text": "Then run tests."}]}},
{"type": "assistant", "message": {"content": [{"type": "text", "text":
"Then run tests.\n" + fake_pem("RSA PRIVATE KEY") + "\nDone."}]}},
])
run(export_claude, ["--src", str(tmp / "src"), "--dest", str(tmp / "out")])
text = only_md(tmp / "out")
expect("How do I ship?" in text and "Then run tests." in text, "claude: user prompt and both replies kept")
expect("How do I ship?" in text and "Then run tests." in text and "Done." in text,
"claude: user prompt and both replies kept")
expect("<REDACTED_PRIVKEY>" in text and "QUJDREVGRw==" not in text,
"claude: PEM blocks must be removed before writing Markdown")
for dropped in ("injected", "hidden", "tool noise", "subagent", KEY):
expect(dropped not in text, f"claude: {dropped!r} should not be exported")
# Watermark: an unchanged rerun writes nothing.
Expand Down Expand Up @@ -125,7 +173,7 @@ def test_grok(tmp):


def main():
for test in (test_claude, test_codex_rollout_keeps_final_answer, test_codex_thread_history,
for test in (test_redaction, test_claude, test_codex_rollout_keeps_final_answer, test_codex_thread_history,
test_cursor_app, test_grok):
with tempfile.TemporaryDirectory() as tmp:
test(Path(tmp))
Expand Down
Loading