reasonwork 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,908 @@
1
+ """Streaming, content-safe checks for Codex and Claude Code JSONL sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from collections import Counter
9
+ from dataclasses import asdict, dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ MAX_TRAJECTORY_BYTES = 100 * 1024 * 1024
14
+ HARD_QUALITY_REJECTION_CODES = frozenset(
15
+ {"unrecognized_source", "invalid_json_lines", "too_few_messages", "no_agentic_tool_use"}
16
+ )
17
+
18
+
19
+ def _luhn_valid(candidate: str) -> bool:
20
+ """Checksum-validate a digit string shaped like a card number.
21
+
22
+ The credit_card pattern below only matches BIN prefix + length, which
23
+ also matches plenty of incidental digit runs (UUIDs, hashes, session
24
+ ids) that are not card numbers. Luhn rejects the vast majority of those.
25
+ """
26
+ digits = [int(d) for d in candidate if d.isdigit()]
27
+ if not (13 <= len(digits) <= 19):
28
+ return False
29
+ total = 0
30
+ for i, digit in enumerate(reversed(digits)):
31
+ if i % 2 == 1:
32
+ digit *= 2
33
+ if digit > 9:
34
+ digit -= 9
35
+ total += digit
36
+ return total % 10 == 0
37
+
38
+ _SECRET_PATTERNS = {
39
+ # Matches the whole PEM block through its matching END marker so the base64 body
40
+ # isn't left in plaintext after redaction; falls back to the header alone if no
41
+ # END marker follows (e.g. a truncated key), which is why the alternative is kept.
42
+ "private_key": re.compile(
43
+ r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"
44
+ r"|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"
45
+ ),
46
+ "aws_access_key": re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"),
47
+ "openai_key": re.compile(r"\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{20,}\b"),
48
+ "anthropic_key": re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}\b"),
49
+ "github_token": re.compile(r"\bgh[opusr]_[A-Za-z0-9]{20,}\b"),
50
+ "bearer_token": re.compile(r"\bBearer\s+[A-Za-z0-9._~-]{24,}\b", re.IGNORECASE),
51
+ "slack_token": re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"),
52
+ "slack_webhook": re.compile(r"\bhooks\.slack\.com/services/[A-Za-z0-9/]+\b"),
53
+ "stripe_key": re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{20,}\b"),
54
+ "google_api_key": re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
55
+ "npm_token": re.compile(r"\bnpm_[A-Za-z0-9]{36}\b"),
56
+ "db_connection_password": re.compile(
57
+ r"\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^\s\"'/:@]+:[^\s\"'/@]+@[^\s\"']+",
58
+ re.IGNORECASE,
59
+ ),
60
+ "jwt": re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
61
+ "ssn": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
62
+ "credit_card": re.compile(
63
+ r"\b(?:4[0-9]{3}|5[1-5][0-9]{2}|3[47][0-9]{2}|6011)[- ]?[0-9]{4}[- ]?[0-9]{4}[- ]?[0-9]{1,4}\b"
64
+ ),
65
+ "twilio_api_key": re.compile(r"\bSK[a-f0-9]{32}\b"),
66
+ "twilio_account_sid": re.compile(r"\bAC[a-f0-9]{32}\b"),
67
+ "sendgrid_key": re.compile(r"\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b"),
68
+ "mailgun_key": re.compile(r"\bkey-[a-f0-9]{32}\b"),
69
+ "digitalocean_token": re.compile(r"\bdop_v1_[a-f0-9]{64}\b"),
70
+ "azure_storage_key": re.compile(r"\bAccountKey=[A-Za-z0-9+/=]{60,}"),
71
+ "generic_api_secret": re.compile(
72
+ r"\b(?:api[_-]?key|api[_-]?secret|access[_-]?token|client[_-]?secret|secret[_-]?key)"
73
+ r"\s*[=:]\s*\\?[\"']?[A-Za-z0-9_\-.]{16,}\\?[\"']?",
74
+ re.IGNORECASE,
75
+ ),
76
+ "generic_password_assignment": re.compile(
77
+ r"\b(?:password|passwd|pwd)\s*[=:]\s*\\?[\"'][^\"'\s]{6,}\\?[\"']", re.IGNORECASE
78
+ ),
79
+ # --- Fixed-prefix service tokens (documented public formats) ---
80
+ "github_fine_grained_pat": re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"),
81
+ "gitlab_personal_access_token": re.compile(r"\bglpat-[A-Za-z0-9_-]{20}\b"),
82
+ "gitlab_pipeline_trigger_token": re.compile(r"\bglptt-[A-Za-z0-9]{20,}\b"),
83
+ "gitlab_deploy_token": re.compile(r"\bgldt-[A-Za-z0-9_-]{20,}\b"),
84
+ "gitlab_runner_registration_token": re.compile(r"\bGR1348941[A-Za-z0-9_-]{20,}\b"),
85
+ "dockerhub_personal_access_token": re.compile(r"\bdckr_pat_[A-Za-z0-9_-]{20,}\b"),
86
+ "vault_service_token": re.compile(r"\bhvs\.[A-Za-z0-9_-]{24,}\b"),
87
+ "vault_batch_token": re.compile(r"\bhvb\.[A-Za-z0-9_-]{24,}\b"),
88
+ "digitalocean_oauth_token": re.compile(r"\bdoo_v1_[a-f0-9]{64}\b"),
89
+ "digitalocean_refresh_token": re.compile(r"\bdor_v1_[a-f0-9]{64}\b"),
90
+ "pypi_upload_token": re.compile(r"\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,}\b"),
91
+ "mailchimp_api_key": re.compile(r"\b[a-f0-9]{32}-us\d{1,2}\b"),
92
+ "facebook_access_token": re.compile(r"\bEAA[A-Za-z0-9]{20,}\b"),
93
+ "telegram_bot_token": re.compile(r"\b\d{8,10}:[A-Za-z0-9_-]{35}\b"),
94
+ "discord_bot_token": re.compile(r"\b[MN][A-Za-z0-9_-]{23,25}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,38}\b"),
95
+ "shopify_admin_api_token": re.compile(r"\bshpat_[a-fA-F0-9]{32}\b"),
96
+ "shopify_shared_secret": re.compile(r"\bshpss_[a-fA-F0-9]{32}\b"),
97
+ "shopify_custom_app_token": re.compile(r"\bshpca_[a-fA-F0-9]{32}\b"),
98
+ "contentful_personal_access_token": re.compile(r"\bCFPAT-[A-Za-z0-9_-]{20,}\b"),
99
+ "square_access_token": re.compile(r"\bEAAA[A-Za-z0-9_-]{20,}\b"),
100
+ "square_oauth_secret": re.compile(r"\bsq0(?:csp|atp)-[A-Za-z0-9_-]{22,43}\b"),
101
+ "razorpay_key": re.compile(r"\brzp_(?:live|test)_[A-Za-z0-9]{14,}\b"),
102
+ "adyen_api_key": re.compile(r"\bAQE[A-Za-z0-9+/=_-]{20,}\b"),
103
+ "huggingface_token": re.compile(r"\bhf_[A-Za-z0-9]{34}\b"),
104
+ "replicate_token": re.compile(r"\br8_[A-Za-z0-9]{40}\b"),
105
+ "perplexity_api_key": re.compile(r"\bpplx-[A-Za-z0-9]{40,}\b"),
106
+ "groq_api_key": re.compile(r"\bgsk_[A-Za-z0-9]{52}\b"),
107
+ "fireworks_api_key": re.compile(r"\bfw_[A-Za-z0-9]{20,}\b"),
108
+ "cerebras_api_key": re.compile(r"\bcsk-[A-Za-z0-9]{20,}\b"),
109
+ "openrouter_api_key": re.compile(r"\bsk-or-v1-[a-f0-9]{40,}\b"),
110
+ "xai_api_key": re.compile(r"\bxai-[A-Za-z0-9]{20,}\b"),
111
+ "new_relic_user_api_key": re.compile(r"\bNRAK-[A-Z0-9]{27}\b"),
112
+ "sentry_dsn": re.compile(r"https://[a-f0-9]{32}@[a-z0-9.-]+\.ingest(?:\.us)?\.sentry\.io/\d+"),
113
+ "launchdarkly_mobile_key": re.compile(r"\bmob-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b"),
114
+ "hubspot_private_app_token": re.compile(r"\bpat-(?:na1|eu1)-[a-f0-9-]{36}\b"),
115
+ "slack_app_level_token": re.compile(r"\bxapp-1-[A-Za-z0-9-]{20,}\b"),
116
+ "slack_config_token": re.compile(r"\bxoxe\.xox[a-z]-1-[A-Za-z0-9-]{20,}\b"),
117
+ "netlify_personal_access_token": re.compile(r"\bnfp_[A-Za-z0-9]{40,}\b"),
118
+ "render_api_key": re.compile(r"\brnd_[A-Za-z0-9]{20,}\b"),
119
+ "planetscale_service_token": re.compile(r"\bpscale_tkn_[A-Za-z0-9]{20,}\b"),
120
+ "planetscale_oauth_token": re.compile(r"\bpscale_oauth_[A-Za-z0-9]{20,}\b"),
121
+ "figma_personal_access_token": re.compile(r"\bfigd_[A-Za-z0-9_-]{20,}\b"),
122
+ "linear_api_key": re.compile(r"\blin_api_[A-Za-z0-9]{20,}\b"),
123
+ "notion_api_key": re.compile(r"\bntn_[A-Za-z0-9]{20,}\b"),
124
+ "notion_api_key_legacy": re.compile(r"\bsecret_[A-Za-z0-9]{43}\b"),
125
+ "airtable_personal_access_token": re.compile(r"\bpat[A-Za-z0-9]{14}\.[a-f0-9]{64}\b"),
126
+ "postman_api_key": re.compile(r"\bPMAK-[a-f0-9]{24}-[a-f0-9]{34}\b"),
127
+ "sourcegraph_access_token": re.compile(r"\bsgp_[A-Za-z0-9_]{20,}\b"),
128
+ "grafana_service_account_token": re.compile(r"\bglsa_[A-Za-z0-9_]{32,}\b"),
129
+ "cloudinary_url": re.compile(r"\bcloudinary://[A-Za-z0-9]+:[A-Za-z0-9]+@[A-Za-z0-9_-]+\b"),
130
+ "amazon_mws_auth_token": re.compile(r"\bamzn\.mws\.[0-9a-fA-F-]{36}\b"),
131
+ "alibaba_cloud_access_key_id": re.compile(r"\bLTAI[A-Za-z0-9]{12,20}\b"),
132
+ "tencent_cloud_secret_id": re.compile(r"\bAKID[A-Za-z0-9]{32,36}\b"),
133
+ "firebase_cloud_messaging_legacy_key": re.compile(r"\bAAAA[A-Za-z0-9_-]{7}:[A-Za-z0-9_-]{140,}\b"),
134
+ "mapbox_secret_token": re.compile(r"\bsk\.[A-Za-z0-9]{60,}\.[A-Za-z0-9_-]{20,}\b"),
135
+ "mapbox_public_token": re.compile(r"\bpk\.[A-Za-z0-9]{60,}\.[A-Za-z0-9_-]{20,}\b"),
136
+ # --- Context-gated secrets (no fixed prefix; require a nearby key name) ---
137
+ "algolia_admin_key_assignment": re.compile(
138
+ r"\balgolia[_-]?(?:admin)?[_-]?api[_-]?key\s*[=:]\s*[\"']?[A-Za-z0-9]{32}[\"']?", re.IGNORECASE
139
+ ),
140
+ "okta_api_token_assignment": re.compile(
141
+ r"\bokta[_-]?api[_-]?token\s*[=:]\s*[\"']?00[A-Za-z0-9_-]{40}[\"']?", re.IGNORECASE
142
+ ),
143
+ "auth0_client_secret_assignment": re.compile(
144
+ r"\bauth0[_-]?client[_-]?secret\s*[=:]\s*[\"']?[A-Za-z0-9_-]{32,}[\"']?", re.IGNORECASE
145
+ ),
146
+ "datadog_api_key_assignment": re.compile(
147
+ r"\bdd[_-]?api[_-]?key\s*[=:]\s*[\"']?[a-f0-9]{32}[\"']?", re.IGNORECASE
148
+ ),
149
+ "pagerduty_api_key_assignment": re.compile(
150
+ r"\bpagerduty[_-]?api[_-]?key\s*[=:]\s*[\"']?[A-Za-z0-9+_-]{20,}[\"']?", re.IGNORECASE
151
+ ),
152
+ "segment_write_key_assignment": re.compile(
153
+ r"\bsegment[_-]?write[_-]?key\s*[=:]\s*[\"']?[A-Za-z0-9]{20,}[\"']?", re.IGNORECASE
154
+ ),
155
+ "amplitude_api_key_assignment": re.compile(
156
+ r"\bamplitude[_-]?api[_-]?key\s*[=:]\s*[\"']?[a-f0-9]{32}[\"']?", re.IGNORECASE
157
+ ),
158
+ "cloudflare_api_token_assignment": re.compile(
159
+ r"\bcloudflare[_-]?(?:api)?[_-]?token\s*[=:]\s*[\"']?[A-Za-z0-9_-]{40}[\"']?", re.IGNORECASE
160
+ ),
161
+ "heroku_api_key_assignment": re.compile(
162
+ r"\bheroku[_-]?api[_-]?key\s*[=:]\s*[\"']?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}[\"']?",
163
+ re.IGNORECASE,
164
+ ),
165
+ "jira_api_token_assignment": re.compile(
166
+ r"\bjira[_-]?api[_-]?token\s*[=:]\s*[\"']?[A-Za-z0-9]{24}[\"']?", re.IGNORECASE
167
+ ),
168
+ "zendesk_api_token_assignment": re.compile(
169
+ r"\bzendesk[_-]?api[_-]?token\s*[=:]\s*[\"']?[A-Za-z0-9]{20,}[\"']?", re.IGNORECASE
170
+ ),
171
+ # --- Structurally-standardized government/financial identifiers (PII) ---
172
+ "iban": re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b"),
173
+ "uk_national_insurance_number": re.compile(r"\b[A-CEGHJ-PR-TW-Z]{1}[A-CEGHJ-NPR-TW-Z]{1}\d{6}[A-D]\b"),
174
+ "india_pan": re.compile(r"\b[A-Z]{5}\d{4}[A-Z]\b"),
175
+ "india_aadhaar": re.compile(r"\b\d{4}\s\d{4}\s\d{4}\b"),
176
+ "us_itin": re.compile(r"\b9\d{2}-\d{2}-\d{4}\b"),
177
+ "passport_mrz_line": re.compile(r"\bP<[A-Z<]{43}\b"),
178
+ "drivers_license_assignment": re.compile(
179
+ r"driver'?s?\s*licen[cs]e\s*(?:number|#|no\.?)?\s*[:#]?\s*[A-Z0-9]{6,20}", re.IGNORECASE
180
+ ),
181
+ "medical_record_number": re.compile(r"\bMRN\s*[:#]?\s*\d{6,10}\b", re.IGNORECASE),
182
+ "date_of_birth": re.compile(
183
+ r"\b(?:DOB|date of birth)\s*[:\-]?\s*(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|\d{4}-\d{2}-\d{2})\b", re.IGNORECASE
184
+ ),
185
+ "us_bank_routing_number": re.compile(
186
+ r"\brouting\s*(?:number|no\.?|#)?\s*[:#]?\s*\d{9}\b", re.IGNORECASE
187
+ ),
188
+ "swift_bic_code": re.compile(
189
+ r"\bswift\s*(?:code)?\s*[:#]?\s*[A-Z]{6}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b", re.IGNORECASE
190
+ ),
191
+ "brazil_cpf": re.compile(r"\b\d{3}\.\d{3}\.\d{3}-\d{2}\b"),
192
+ "brazil_cnpj": re.compile(r"\b\d{2}\.\d{3}\.\d{3}/\d{4}-\d{2}\b"),
193
+ "south_africa_id_number": re.compile(r"\b\d{6}[01]\d{3}[01]\d{2}\b"),
194
+ "spain_dni_nie": re.compile(r"\b(?:[XYZ]\d{7}|\d{8})[A-Za-z]\b"),
195
+ "italy_codice_fiscale": re.compile(r"\b[A-Z]{6}\d{2}[A-EHLMPRST]\d{2}[A-Z]\d{3}[A-Z]\b"),
196
+ "sweden_personnummer": re.compile(r"\b\d{6}[-+]\d{4}\b"),
197
+ "singapore_nric_fin": re.compile(r"\b[STFG]\d{7}[A-Z]\b"),
198
+ "south_korea_resident_registration_number": re.compile(r"\b\d{6}-[1-4]\d{6}\b"),
199
+ "mexico_curp": re.compile(r"\b[A-Z]{4}\d{6}[HM][A-Z]{5}[A-Z0-9]\d\b"),
200
+ "russia_snils": re.compile(r"\b\d{3}-\d{3}-\d{3}\s\d{2}\b"),
201
+ "france_insee_number": re.compile(r"\b[12]\d{2}(?:0[1-9]|1[0-2])\d{2}\d{3}\d{3}\d{2}\b"),
202
+ "netherlands_bsn_assignment": re.compile(r"\bBSN\s*[:#]?\s*\d{8,9}\b", re.IGNORECASE),
203
+ "australia_tfn_assignment": re.compile(r"\b(?:TFN|tax file number)\s*[:#]?\s*\d{8,9}\b", re.IGNORECASE),
204
+ "germany_tax_id_assignment": re.compile(r"\b(?:steuer-?id|tax\s*id)\s*[:#]?\s*\d{11}\b", re.IGNORECASE),
205
+ "japan_my_number_assignment": re.compile(r"\bmy\s*number\s*[:#]?\s*\d{12}\b", re.IGNORECASE),
206
+ "passport_number_assignment": re.compile(
207
+ r"\bpassport\s*(?:number|no\.?|#)?\s*[:#]?\s*[A-Z0-9]{6,9}\b", re.IGNORECASE
208
+ ),
209
+ # --- Generic secret shapes (not tied to a specific vendor) ---
210
+ "generic_basic_auth_url": re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s\"'/:@]+:[^\s\"'/@]+@[^\s\"']+"),
211
+ "generic_basic_auth_header": re.compile(r"Authorization:\s*Basic\s+[A-Za-z0-9+/=]{16,}", re.IGNORECASE),
212
+ "generic_authorization_header_token": re.compile(
213
+ r"Authorization:\s*(?:Token|ApiKey|Api-Key)\s+[A-Za-z0-9._~-]{16,}", re.IGNORECASE
214
+ ),
215
+ "generic_token_assignment": re.compile(
216
+ r"\b(?:auth|session|refresh|private|signing|webhook)[_-]?token"
217
+ r"\s*[=:]\s*\\?[\"']?[A-Za-z0-9_\-.]{16,}\\?[\"']?",
218
+ re.IGNORECASE,
219
+ ),
220
+ "generic_encryption_key_assignment": re.compile(
221
+ r"\b(?:encryption|master|signing)[_-]?(?:key|secret)"
222
+ r"\s*[=:]\s*\\?[\"']?[A-Za-z0-9_\-+/=]{16,}\\?[\"']?",
223
+ re.IGNORECASE,
224
+ ),
225
+ "odbc_jdbc_connection_string_password": re.compile(r"\bPassword\s*=\s*[^;\s\"']{4,};", re.IGNORECASE),
226
+ "generic_api_key_in_query_string": re.compile(
227
+ r"[?&](?:api_key|apikey|access_token|token|key)=[A-Za-z0-9_\-]{16,}", re.IGNORECASE
228
+ ),
229
+ "pgp_private_key_block": re.compile(
230
+ r"-----BEGIN PGP PRIVATE KEY BLOCK-----[\s\S]*?-----END PGP PRIVATE KEY BLOCK-----"
231
+ r"|-----BEGIN PGP PRIVATE KEY BLOCK-----"
232
+ ),
233
+ "encrypted_private_key_block": re.compile(
234
+ r"-----BEGIN ENCRYPTED PRIVATE KEY-----[\s\S]*?-----END ENCRYPTED PRIVATE KEY-----"
235
+ r"|-----BEGIN ENCRYPTED PRIVATE KEY-----"
236
+ ),
237
+ "putty_private_key": re.compile(r"PuTTY-User-Key-File-\d+"),
238
+ # --- Additional vendor secrets (documented formats, cf. TruffleHog detectors) ---
239
+ "stripe_webhook_secret": re.compile(r"\bwhsec_[A-Za-z0-9]{32,}\b"),
240
+ "stripe_test_key": re.compile(r"\b(?:sk|rk)_test_[A-Za-z0-9]{20,}\b"),
241
+ "databricks_token": re.compile(r"\bdapi[a-f0-9]{32}(?:-\d)?\b"),
242
+ "artifactory_api_key": re.compile(r"\bAKCp[A-Za-z0-9]{69,73}\b"),
243
+ "dropbox_short_lived_token": re.compile(r"\bsl\.[A-Za-z0-9_-]{60,}\b"),
244
+ "wakatime_api_key": re.compile(r"\bwaka_[a-f0-9-]{36}\b"),
245
+ "microsoft_teams_webhook": re.compile(
246
+ r"\bhttps://[a-z0-9.-]+\.webhook\.office\.com/webhookb2/[A-Za-z0-9@/_-]+\b"
247
+ ),
248
+ "gcp_service_account_json_marker": re.compile(
249
+ r"\\?\"type\\?\"\s*:\s*\\?\"service_account\\?\""
250
+ ),
251
+ "aws_secret_access_key_assignment": re.compile(
252
+ r"aws_secret_access_key\s*[=:]\s*\\?[\"']?[A-Za-z0-9/+=]{40}\\?[\"']?", re.IGNORECASE
253
+ ),
254
+ # --- Azure ---
255
+ "azure_shared_access_key": re.compile(r"SharedAccessKey\s*=\s*[A-Za-z0-9+/=]{20,}"),
256
+ "azure_service_bus_connection_string": re.compile(
257
+ r"Endpoint=sb://[^;\s\"']+;SharedAccessKeyName=[^;\s\"']+;SharedAccessKey=[A-Za-z0-9+/=]{20,}"
258
+ ),
259
+ "azure_sas_token": re.compile(
260
+ r"[?&]sv=\d{4}-\d{2}-\d{2}-?[^\s\"']*&[^\s\"']*sig=[A-Za-z0-9%]{20,}"
261
+ ),
262
+ "azure_openai_api_key_assignment": re.compile(
263
+ r"azure_openai_api_key\s*[=:]\s*\\?[\"']?[a-f0-9]{32}\\?[\"']?", re.IGNORECASE
264
+ ),
265
+ # --- Supabase ---
266
+ "supabase_service_role_key_assignment": re.compile(
267
+ r"SUPABASE_SERVICE_ROLE_KEY\s*[=:]\s*\\?[\"']?eyJ[A-Za-z0-9_-]{10,}", re.IGNORECASE
268
+ ),
269
+ "supabase_anon_key_assignment": re.compile(
270
+ r"SUPABASE_ANON_KEY\s*[=:]\s*\\?[\"']?eyJ[A-Za-z0-9_-]{10,}", re.IGNORECASE
271
+ ),
272
+ # --- More fixed-prefix service tokens ---
273
+ "fly_io_api_token": re.compile(r"\bfm(?:1[ab]?|2)_[A-Za-z0-9+/_=-]{20,}\b"),
274
+ "cloudflare_origin_ca_key": re.compile(r"\bv1\.0-[a-f0-9-]{60,}\b"),
275
+ "onepassword_service_account_token": re.compile(r"\bops_[A-Za-z0-9+/=_-]{40,}\b"),
276
+ "doppler_token": re.compile(r"\bdp\.(?:st|ct|pt|sa)\.[A-Za-z0-9]{40,50}\b"),
277
+ "sentry_internal_integration_token": re.compile(r"\bsntrys_[A-Za-z0-9_]{20,}\b"),
278
+ "supabase_personal_access_token": re.compile(r"\bsbp_[a-f0-9]{40}\b"),
279
+ "plaid_access_token": re.compile(r"\baccess-(?:sandbox|development|production)-[a-f0-9-]{36}\b"),
280
+ "langsmith_api_key": re.compile(r"\blsv2_(?:pt|sk)_[A-Za-z0-9]{20,}\b"),
281
+ "resend_api_key": re.compile(r"\bre_[A-Za-z0-9]{32,}\b"),
282
+ # --- More PII / national identifiers ---
283
+ "us_medicare_beneficiary_identifier": re.compile(r"\b[1-9][A-Z][A-Z0-9]\d[A-Z][A-Z0-9]\d[A-Z]{2}\d{2}\b"),
284
+ "dea_registration_number": re.compile(r"\b[ABFGHJKLMPRSTUX][A-Z]\d{7}\b"),
285
+ "uk_nhs_number": re.compile(r"\b\d{3}\s\d{3}\s\d{4}\b"),
286
+ "ireland_pps_number": re.compile(r"\b\d{7}[A-Z]{1,2}\b"),
287
+ "switzerland_ahv_number": re.compile(r"\b756\.\d{4}\.\d{4}\.\d{2}\b"),
288
+ "finland_henkilotunnus": re.compile(r"\b\d{6}[-+A]\d{3}[0-9A-Z]\b"),
289
+ "imei_number_assignment": re.compile(r"\bIMEI\s*[:#]?\s*\d{15}\b", re.IGNORECASE),
290
+ "npi_number_assignment": re.compile(r"\bNPI\s*[:#]?\s*\d{10}\b", re.IGNORECASE),
291
+ "nz_ird_number_assignment": re.compile(r"\bIRD\s*(?:number)?\s*[:#]?\s*\d{8,9}\b", re.IGNORECASE),
292
+ "bank_account_number_assignment": re.compile(r"\baccount\s*number\s*[:#]?\s*\d{8,17}\b", re.IGNORECASE),
293
+ "cvv_assignment": re.compile(r"\bCVV2?\s*[:#]?\s*\d{3,4}\b", re.IGNORECASE),
294
+ "diners_club_card": re.compile(r"\b3(?:0[0-5]|[68][0-9])[0-9]{11}\b"),
295
+ "jcb_card": re.compile(r"\b(?:2131|1800|35\d{2})\d{12}\b"),
296
+ }
297
+ _EMAIL_PATTERN = re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE)
298
+ _PHONE_PATTERN = re.compile(
299
+ r"\b(?:\+?1[-.\s])?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b"
300
+ r"|\+[1-9]\d{0,3}(?:[-.\s]?\d{2,4}){2,4}\b"
301
+ )
302
+ _CODEX_TYPES = {"session_meta", "event_msg", "response_item", "turn_context"}
303
+ _CLAUDE_TYPES = {"user", "assistant", "system", "file-history-snapshot", "queue-operation"}
304
+
305
+
306
+ @dataclass(frozen=True)
307
+ class Analysis:
308
+ """Metadata-only analysis result; never contains prompt or tool-output text."""
309
+
310
+ source: str | None
311
+ session_id: str | None
312
+ agent_id: str | None
313
+ content_sha256: str
314
+ byte_size: int
315
+ line_count: int
316
+ valid_json_lines: int
317
+ invalid_json_lines: int
318
+ event_types: dict[str, int]
319
+ message_count: int
320
+ tool_call_count: int
321
+ tool_result_count: int
322
+ secret_findings: dict[str, int]
323
+ email_count: int
324
+ phone_count: int
325
+ privacy_risk: str
326
+ base_score: int
327
+ anti_gaming_adjustment: float
328
+ score_flags: list[str]
329
+ deterministic_score: int
330
+ estimated_cents: int
331
+ rejection_codes: list[str]
332
+ # Per-component breakdown of base_score (tool_diversity, code_edit_presence,
333
+ # message_density, error_recovery, session_substance) -- kept alongside the
334
+ # total so a price can be explained/audited component-by-component instead
335
+ # of just as one opaque number. Defaulted so existing positional/keyword
336
+ # constructions of Analysis (tests, etc.) don't all need updating.
337
+ score_components: dict[str, float] = field(default_factory=dict)
338
+ # True when this file is itself a subagent/delegate transcript rather than
339
+ # a top-level session. For Claude Code and Cursor this mirrors discover()'s
340
+ # path-based guess (a file living under a subagents/ folder); for Codex,
341
+ # which writes subagent rollouts as ordinary sibling files with no path
342
+ # signal, this is the only source of truth -- derived from the session's
343
+ # own session_meta record (thread_source == "subagent").
344
+ is_subagent: bool = False
345
+ # Raw counters feeding the real scoring formula, which deliberately does
346
+ # NOT live in this published package anymore (see ui/contributor-ui/
347
+ # backend_workers/pricing.py). None of these individually reveal the
348
+ # formula's weights/thresholds -- they're just counts -- so it's safe for
349
+ # the CLI to compute and report them; only the worker turns them into an
350
+ # actual score/price.
351
+ distinct_tool_name_count: int = 0
352
+ edit_call_count: int = 0
353
+ text_char_count: int = 0
354
+ error_result_count: int = 0
355
+ recovered_error_count: int = 0
356
+ tool_signature_counts: dict[str, int] = field(default_factory=dict)
357
+ text_message_count: int = 0
358
+ duplicate_text_message_count: int = 0
359
+
360
+ def to_dict(self) -> dict[str, Any]:
361
+ """Return the stable JSON representation used by CLI and worker."""
362
+ return asdict(self)
363
+
364
+
365
+ def _nested_type(payload: dict[str, Any]) -> str | None:
366
+ value = payload.get("type")
367
+ return value if isinstance(value, str) else None
368
+
369
+
370
+ def _session_id(record: dict[str, Any], source: str | None) -> str | None:
371
+ if source == "codex":
372
+ payload = record.get("payload")
373
+ if isinstance(payload, dict):
374
+ # A Codex subagent's own thread id is distinct from its parent's --
375
+ # unlike Claude Code, where a subagent transcript's records are
376
+ # tagged with the *same* sessionId as the parent conversation.
377
+ # Returning parent_thread_id here (when present) instead of the
378
+ # subagent's own id makes Codex subagents group under their parent
379
+ # the same way, via the shared client_session_id sent to the
380
+ # server -- no separate linking field needed downstream.
381
+ parent = payload.get("parent_thread_id")
382
+ if isinstance(parent, str) and parent:
383
+ return parent
384
+ for key in ("id", "session_id", "thread_id"):
385
+ value = payload.get(key)
386
+ if isinstance(value, str) and value:
387
+ return value
388
+ for key in ("sessionId", "session_id", "uuid"):
389
+ value = record.get(key)
390
+ if isinstance(value, str) and value:
391
+ return value
392
+ return None
393
+
394
+
395
+ def _codex_is_subagent(record: dict[str, Any]) -> bool:
396
+ """True if this record's payload marks the session as a Codex subagent/delegate thread."""
397
+ payload = record.get("payload")
398
+ return isinstance(payload, dict) and payload.get("thread_source") == "subagent"
399
+
400
+
401
+ def _codex_agent_id(record: dict[str, Any]) -> str | None:
402
+ payload = record.get("payload")
403
+ if not isinstance(payload, dict):
404
+ return None
405
+ for key in ("agent_nickname", "agent_path", "agent_role"):
406
+ value = payload.get(key)
407
+ if isinstance(value, str) and value:
408
+ return value
409
+ return None
410
+
411
+
412
+ _EDIT_TOOL_NAMES = {"Edit", "Write", "NotebookEdit", "MultiEdit", "apply_patch"}
413
+
414
+
415
+ @dataclass
416
+ class _ClaudeEvents:
417
+ """Per-record tallies feeding the base quality score and anti-gaming checks."""
418
+
419
+ tool_calls: int = 0
420
+ tool_results: int = 0
421
+ edit_calls: int = 0
422
+ tool_names: list[str] | None = None
423
+ tool_signatures: list[str] | None = None
424
+ error_results: int = 0
425
+ text_chars: int = 0
426
+ text_messages: list[str] | None = None
427
+
428
+
429
+ def _claude_events(record: dict[str, Any]) -> _ClaudeEvents:
430
+ events = _ClaudeEvents(tool_names=[], tool_signatures=[], text_messages=[])
431
+ message = record.get("message")
432
+ if not isinstance(message, dict):
433
+ return events
434
+ content = message.get("content")
435
+ if not isinstance(content, list):
436
+ return events
437
+ for item in content:
438
+ if not isinstance(item, dict):
439
+ continue
440
+ item_type = item.get("type")
441
+ if item_type == "tool_use":
442
+ events.tool_calls += 1
443
+ name = item.get("name")
444
+ name = name if isinstance(name, str) else "(unknown)"
445
+ events.tool_names.append(name)
446
+ events.edit_calls += int(name in _EDIT_TOOL_NAMES)
447
+ # A cheap same-call-repeated-verbatim signature: tool name plus its
448
+ # serialized args, truncated so huge payloads don't blow up the tally.
449
+ try:
450
+ args_repr = json.dumps(item.get("input"), sort_keys=True, default=str)[:500]
451
+ except (TypeError, ValueError):
452
+ args_repr = str(item.get("input"))[:500]
453
+ events.tool_signatures.append(f"{name}:{args_repr}")
454
+ elif item_type == "tool_result":
455
+ events.tool_results += 1
456
+ events.error_results += int(bool(item.get("is_error")))
457
+ elif item_type == "text":
458
+ text = item.get("text")
459
+ if isinstance(text, str):
460
+ events.text_chars += len(text)
461
+ events.text_messages.append(text)
462
+ if isinstance(content, str):
463
+ events.text_chars += len(content)
464
+ events.text_messages.append(content)
465
+ return events
466
+
467
+
468
+ def analyze(path: Path, discovered_source: str | None = None) -> Analysis:
469
+ """Analyze one JSONL session without retaining raw content in the result.
470
+
471
+ discovered_source is a hint from discover() (e.g. "cursor"), used only to
472
+ relabel the final source when the file's own content is schema-compatible
473
+ with another source (Cursor CLI transcripts are Claude-Code-compatible
474
+ JSONL) and would otherwise be misattributed. Content sniffing still drives
475
+ which parsing branch runs, since that reflects the file's real schema.
476
+ """
477
+ size = path.stat().st_size
478
+ if size <= 0:
479
+ raise ValueError("trajectory is empty")
480
+ if size > MAX_TRAJECTORY_BYTES:
481
+ raise ValueError("trajectory exceeds the 100 MB upload limit")
482
+
483
+ digest = hashlib.sha256()
484
+ event_types: Counter[str] = Counter()
485
+ secrets: Counter[str] = Counter()
486
+ valid = 0
487
+ invalid = 0
488
+ line_count = 0
489
+ messages = 0
490
+ tool_calls = 0
491
+ tool_results = 0
492
+ email_count = 0
493
+ phone_count = 0
494
+ source: str | None = None
495
+ session_id: str | None = None
496
+ agent_id: str | None = None
497
+ is_subagent = False
498
+ tool_names: set[str] = set()
499
+ edit_calls = 0
500
+ error_results = 0
501
+ recovered_errors = 0
502
+ pending_error = False
503
+ text_chars = 0
504
+ tool_signatures: Counter[str] = Counter()
505
+ text_messages: Counter[str] = Counter()
506
+
507
+ with path.open("rb") as handle:
508
+ for raw in handle:
509
+ digest.update(raw)
510
+ if not raw.strip():
511
+ continue
512
+ line_count += 1
513
+ text = raw.decode("utf-8", errors="replace")
514
+ for name, pattern in _SECRET_PATTERNS.items():
515
+ matches = pattern.findall(text)
516
+ if name == "credit_card":
517
+ matches = [m for m in matches if _luhn_valid(m)]
518
+ secrets[name] += len(matches)
519
+ email_count += len(_EMAIL_PATTERN.findall(text))
520
+ phone_count += len(_PHONE_PATTERN.findall(text))
521
+ try:
522
+ record = json.loads(text)
523
+ except json.JSONDecodeError:
524
+ invalid += 1
525
+ continue
526
+ if not isinstance(record, dict):
527
+ invalid += 1
528
+ continue
529
+ valid += 1
530
+ event_type = record.get("type")
531
+ if not isinstance(event_type, str):
532
+ event_type = "(missing)"
533
+ event_types[event_type] += 1
534
+ if source is None:
535
+ if event_type in _CODEX_TYPES:
536
+ source = "codex"
537
+ elif event_type in _CLAUDE_TYPES:
538
+ source = "claude_code"
539
+ if session_id is None:
540
+ session_id = _session_id(record, source)
541
+ if agent_id is None:
542
+ value = record.get("agentId")
543
+ if isinstance(value, str) and value:
544
+ agent_id = value
545
+ elif source == "codex":
546
+ agent_id = _codex_agent_id(record)
547
+ if source == "codex" and not is_subagent:
548
+ is_subagent = _codex_is_subagent(record)
549
+
550
+ if source == "codex":
551
+ payload = record.get("payload")
552
+ if isinstance(payload, dict):
553
+ payload_type = _nested_type(payload)
554
+ if event_type == "response_item" and payload_type == "message":
555
+ messages += 1
556
+ message_text = _content_text(payload.get("content"))
557
+ text_chars += len(message_text)
558
+ if message_text:
559
+ text_messages[message_text] += 1
560
+ if event_type == "response_item" and payload_type in {
561
+ "function_call",
562
+ "custom_tool_call",
563
+ "local_shell_call",
564
+ }:
565
+ tool_calls += 1
566
+ name = payload.get("name")
567
+ name = name if isinstance(name, str) else payload_type
568
+ tool_names.add(name)
569
+ edit_calls += int(name in _EDIT_TOOL_NAMES)
570
+ try:
571
+ args_repr = json.dumps(payload.get("arguments"), sort_keys=True, default=str)[:500]
572
+ except (TypeError, ValueError):
573
+ args_repr = str(payload.get("arguments"))[:500]
574
+ tool_signatures[f"{name}:{args_repr}"] += 1
575
+ if pending_error:
576
+ recovered_errors += 1
577
+ pending_error = False
578
+ if event_type == "response_item" and payload_type in {
579
+ "function_call_output",
580
+ "custom_tool_call_output",
581
+ "local_shell_call_output",
582
+ }:
583
+ tool_results += 1
584
+ output = payload.get("output")
585
+ is_error = isinstance(output, dict) and bool(output.get("error"))
586
+ error_results += int(is_error)
587
+ pending_error = pending_error or is_error
588
+ elif source == "claude_code":
589
+ messages += int(event_type in {"user", "assistant"})
590
+ events = _claude_events(record)
591
+ tool_calls += events.tool_calls
592
+ tool_results += events.tool_results
593
+ edit_calls += events.edit_calls
594
+ text_chars += events.text_chars
595
+ for msg_text in events.text_messages or []:
596
+ text_messages[msg_text] += 1
597
+ tool_names.update(events.tool_names or [])
598
+ for sig in events.tool_signatures or []:
599
+ tool_signatures[sig] += 1
600
+ if pending_error:
601
+ recovered_errors += 1
602
+ pending_error = False
603
+ if events.error_results:
604
+ error_results += events.error_results
605
+ pending_error = True
606
+
607
+ secret_total = sum(secrets.values())
608
+ if secret_total:
609
+ privacy_risk = "blocked"
610
+ elif email_count >= 10 or phone_count >= 5:
611
+ privacy_risk = "high"
612
+ elif email_count or phone_count:
613
+ privacy_risk = "medium"
614
+ else:
615
+ privacy_risk = "low"
616
+ text_message_count = sum(text_messages.values())
617
+ duplicate_text_message_count = sum(count - 1 for count in text_messages.values() if count > 1)
618
+ # The real deterministic score/price/anti-gaming formula deliberately does
619
+ # NOT run here anymore -- see ui/contributor-ui/backend_workers/pricing.py.
620
+ # This package ships publicly (PyPI); that module never does. base_score/
621
+ # anti_gaming_adjustment/score_flags/deterministic_score/estimated_cents
622
+ # below are neutral placeholders, not an approximation -- the worker
623
+ # overwrites them with the real values after re-downloading and
624
+ # re-analyzing the actual uploaded content, which was always the only
625
+ # authoritative source of truth regardless of what this function reports.
626
+ base_score = 0
627
+ score_components: dict[str, float] = {}
628
+ adjustment = 1.0
629
+ score_flags: list[str] = []
630
+ score = 0
631
+ rejection_codes: list[str] = []
632
+ if source is None:
633
+ rejection_codes.append("unrecognized_source")
634
+ if invalid:
635
+ rejection_codes.append("invalid_json_lines")
636
+ if messages < 2:
637
+ rejection_codes.append("too_few_messages")
638
+ if tool_calls < 1:
639
+ rejection_codes.append("no_agentic_tool_use")
640
+ if secret_total:
641
+ rejection_codes.append("secret_detected")
642
+ if privacy_risk == "high":
643
+ rejection_codes.append("excess_personal_data")
644
+ elif privacy_risk == "medium":
645
+ rejection_codes.append("personal_data_detected")
646
+ estimated_cents = 0
647
+ reported_source = source
648
+ if discovered_source == "cursor" and source in (None, "claude_code"):
649
+ reported_source = "cursor"
650
+
651
+ return Analysis(
652
+ source=reported_source,
653
+ session_id=session_id,
654
+ agent_id=agent_id,
655
+ content_sha256=digest.hexdigest(),
656
+ byte_size=size,
657
+ line_count=line_count,
658
+ valid_json_lines=valid,
659
+ invalid_json_lines=invalid,
660
+ event_types=dict(sorted(event_types.items())),
661
+ message_count=messages,
662
+ tool_call_count=tool_calls,
663
+ tool_result_count=tool_results,
664
+ secret_findings={key: value for key, value in sorted(secrets.items()) if value},
665
+ email_count=email_count,
666
+ phone_count=phone_count,
667
+ privacy_risk=privacy_risk,
668
+ base_score=base_score,
669
+ anti_gaming_adjustment=adjustment,
670
+ score_flags=score_flags,
671
+ deterministic_score=score,
672
+ estimated_cents=estimated_cents,
673
+ rejection_codes=rejection_codes,
674
+ score_components=score_components,
675
+ is_subagent=is_subagent,
676
+ distinct_tool_name_count=len(tool_names),
677
+ edit_call_count=edit_calls,
678
+ text_char_count=text_chars,
679
+ error_result_count=error_results,
680
+ recovered_error_count=recovered_errors,
681
+ tool_signature_counts=dict(tool_signatures),
682
+ text_message_count=text_message_count,
683
+ duplicate_text_message_count=duplicate_text_message_count,
684
+ )
685
+
686
+
687
+ def _content_text(value: object) -> str:
688
+ if isinstance(value, str):
689
+ return value
690
+ if not isinstance(value, list):
691
+ return ""
692
+ chunks: list[str] = []
693
+ for item in value:
694
+ if isinstance(item, str):
695
+ chunks.append(item)
696
+ elif isinstance(item, dict):
697
+ text = item.get("text")
698
+ if isinstance(text, str):
699
+ chunks.append(text)
700
+ return "\n".join(chunks)
701
+
702
+
703
+ def _redact(text: str) -> str:
704
+ for pattern in _SECRET_PATTERNS.values():
705
+ text = pattern.sub("[REDACTED_SECRET]", text)
706
+ return _EMAIL_PATTERN.sub("[REDACTED_EMAIL]", text)
707
+
708
+
709
+ @dataclass
710
+ class RedactionResult:
711
+ """Redacted trajectory bytes plus a re-scan confirming no residual PII/secrets remain."""
712
+
713
+ redacted_bytes: bytes
714
+ content_sha256: str
715
+ byte_size: int
716
+ line_count: int
717
+ redacted_categories: list[str]
718
+ residual_secret_findings: dict[str, int]
719
+ residual_email_count: int
720
+ residual_phone_count: int
721
+
722
+ @property
723
+ def clean(self) -> bool:
724
+ """True once the re-scan of the redacted bytes finds nothing left to redact."""
725
+ return not self.residual_secret_findings and not self.residual_email_count and not self.residual_phone_count
726
+
727
+
728
+ def redact_trajectory(path: Path) -> RedactionResult:
729
+ """Redact secrets/PII from a flagged trajectory, line by line, preserving JSON structure.
730
+
731
+ Substitutions run directly on each line's decoded text rather than parsing JSON: every
732
+ pattern here matches only within a JSON string's content (none can match a `"`), so an
733
+ in-place replacement can never break a line's JSON syntax — this avoids the reformatting
734
+ and key-reordering risk of a parse-and-reserialize approach.
735
+ """
736
+ digest = hashlib.sha256()
737
+ output = bytearray()
738
+ line_count = 0
739
+ categories: set[str] = set()
740
+ residual_secrets: Counter[str] = Counter()
741
+ residual_email = 0
742
+ residual_phone = 0
743
+
744
+ with path.open("rb") as handle:
745
+ for raw in handle:
746
+ if not raw.strip():
747
+ output.extend(raw)
748
+ digest.update(raw)
749
+ continue
750
+ line_count += 1
751
+ text = raw.decode("utf-8", errors="replace")
752
+ for name, pattern in _SECRET_PATTERNS.items():
753
+ if name == "credit_card":
754
+
755
+ def _sub_card(match: re.Match[str]) -> str:
756
+ if _luhn_valid(match.group(0)):
757
+ categories.add("credit_card")
758
+ return "[REDACTED_CREDIT_CARD]"
759
+ return match.group(0)
760
+
761
+ text = pattern.sub(_sub_card, text)
762
+ continue
763
+ if pattern.search(text):
764
+ categories.add(name)
765
+ text = pattern.sub(f"[REDACTED_{name.upper()}]", text)
766
+ if _EMAIL_PATTERN.search(text):
767
+ categories.add("email")
768
+ text = _EMAIL_PATTERN.sub("[REDACTED_EMAIL]", text)
769
+ if _PHONE_PATTERN.search(text):
770
+ categories.add("phone")
771
+ text = _PHONE_PATTERN.sub("[REDACTED_PHONE]", text)
772
+
773
+ encoded = text.encode("utf-8")
774
+ output.extend(encoded)
775
+ digest.update(encoded)
776
+
777
+ for name, pattern in _SECRET_PATTERNS.items():
778
+ matches = pattern.findall(text)
779
+ if name == "credit_card":
780
+ matches = [m for m in matches if _luhn_valid(m)]
781
+ residual_secrets[name] += len(matches)
782
+ residual_email += len(_EMAIL_PATTERN.findall(text))
783
+ residual_phone += len(_PHONE_PATTERN.findall(text))
784
+
785
+ return RedactionResult(
786
+ redacted_bytes=bytes(output),
787
+ content_sha256=digest.hexdigest(),
788
+ byte_size=len(output),
789
+ line_count=line_count,
790
+ redacted_categories=sorted(categories),
791
+ residual_secret_findings={key: value for key, value in sorted(residual_secrets.items()) if value},
792
+ residual_email_count=residual_email,
793
+ residual_phone_count=residual_phone,
794
+ )
795
+
796
+
797
+ def review_sample(path: Path, *, max_messages: int = 6, max_chars: int = 2400) -> list[dict[str, str]]:
798
+ """Extract a tiny redacted message sample for an optional semantic judge.
799
+
800
+ This output is intentionally separate from :class:`Analysis`; the CLI never
801
+ emits it and the worker only uses it after deterministic privacy screening.
802
+ """
803
+ sample: list[dict[str, str]] = []
804
+ used = 0
805
+ with path.open(encoding="utf-8", errors="replace") as handle:
806
+ for line in handle:
807
+ if len(sample) >= max_messages or used >= max_chars:
808
+ break
809
+ try:
810
+ record = json.loads(line)
811
+ except json.JSONDecodeError:
812
+ continue
813
+ if not isinstance(record, dict):
814
+ continue
815
+ role = ""
816
+ text = ""
817
+ if record.get("type") == "response_item" and isinstance(record.get("payload"), dict):
818
+ payload = record["payload"]
819
+ if payload.get("type") == "message":
820
+ role = str(payload.get("role", ""))
821
+ text = _content_text(payload.get("content"))
822
+ elif record.get("type") in {"user", "assistant"} and isinstance(record.get("message"), dict):
823
+ role = str(record["message"].get("role") or record.get("type"))
824
+ text = _content_text(record["message"].get("content"))
825
+ if role not in {"user", "assistant"} or not text.strip():
826
+ continue
827
+ redacted = _redact(text).strip()[:600]
828
+ remaining = max_chars - used
829
+ if remaining <= 0:
830
+ break
831
+ redacted = redacted[:remaining]
832
+ used += len(redacted)
833
+ sample.append({"role": role, "text": redacted})
834
+ return sample
835
+
836
+
837
+ _SIMHASH_BITS = 64
838
+ _TIMESTAMP_RE = re.compile(r"\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2}:\d{2}(\.\d+)?z?", re.IGNORECASE)
839
+ _UUID_RE = re.compile(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", re.IGNORECASE)
840
+
841
+
842
+ def _shingles(text: str, k: int = 8) -> Counter[str]:
843
+ """8-character shingles of normalized text, weighted by frequency.
844
+
845
+ Normalization strips exactly the kind of per-run noise that would
846
+ otherwise make two genuinely-the-same conversations hash completely
847
+ differently -- timestamps and UUIDs, which are unique to every capture
848
+ even when the surrounding content is identical or near-identical.
849
+ """
850
+ normalized = _TIMESTAMP_RE.sub("", text.lower())
851
+ normalized = _UUID_RE.sub("", normalized)
852
+ normalized = re.sub(r"\s+", " ", normalized).strip()
853
+ return Counter(normalized[i : i + k] for i in range(max(0, len(normalized) - k + 1)))
854
+
855
+
856
+ def content_simhash(path: Path) -> int:
857
+ """64-bit SimHash over a session's message text, for fuzzy near-duplicate detection.
858
+
859
+ Worker-only -- like review_sample, the CLI never calls this, since
860
+ comparing against every other contributor's content is meaningless
861
+ without server-side access to it.
862
+
863
+ Like Analysis, the output can't be reversed into readable content --
864
+ it's a lossy bit vector, not a copy of the text. Two sessions whose
865
+ message text is identical or near-identical (same conversation resubmitted
866
+ with edits scattered through it, unlike a byte-exact prefix which the
867
+ existing containment check already catches) produce fingerprints with a
868
+ small Hamming distance; unrelated sessions produce a distance close to
869
+ 32 (half the bits, by construction of SimHash).
870
+ """
871
+ shingle_weights: Counter[str] = Counter()
872
+ with path.open(encoding="utf-8", errors="replace") as handle:
873
+ for line in handle:
874
+ try:
875
+ record = json.loads(line)
876
+ except json.JSONDecodeError:
877
+ continue
878
+ if not isinstance(record, dict):
879
+ continue
880
+ text = ""
881
+ if record.get("type") == "response_item" and isinstance(record.get("payload"), dict):
882
+ payload = record["payload"]
883
+ if payload.get("type") == "message":
884
+ text = _content_text(payload.get("content"))
885
+ elif record.get("type") in {"user", "assistant"} and isinstance(record.get("message"), dict):
886
+ text = _content_text(record["message"].get("content"))
887
+ if text.strip():
888
+ shingle_weights.update(_shingles(text))
889
+
890
+ if not shingle_weights:
891
+ return 0
892
+
893
+ bit_totals = [0] * _SIMHASH_BITS
894
+ for shingle, weight in shingle_weights.items():
895
+ shingle_hash = int(hashlib.sha256(shingle.encode("utf-8")).hexdigest(), 16)
896
+ for bit in range(_SIMHASH_BITS):
897
+ bit_totals[bit] += weight if shingle_hash & (1 << bit) else -weight
898
+
899
+ fingerprint = 0
900
+ for bit, total in enumerate(bit_totals):
901
+ if total > 0:
902
+ fingerprint |= 1 << bit
903
+ return fingerprint
904
+
905
+
906
+ def hamming_distance(a: int, b: int) -> int:
907
+ """Number of differing bits between two fingerprints."""
908
+ return bin(a ^ b).count("1")