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.
- reasonwork-0.4.0.dist-info/METADATA +144 -0
- reasonwork-0.4.0.dist-info/RECORD +25 -0
- reasonwork-0.4.0.dist-info/WHEEL +5 -0
- reasonwork-0.4.0.dist-info/entry_points.txt +2 -0
- reasonwork-0.4.0.dist-info/licenses/LICENSE +52 -0
- reasonwork-0.4.0.dist-info/top_level.txt +1 -0
- reasonwork_exchange/__init__.py +3 -0
- reasonwork_exchange/__main__.py +6 -0
- reasonwork_exchange/analyzer.py +908 -0
- reasonwork_exchange/auth.py +47 -0
- reasonwork_exchange/cli/__init__.py +55 -0
- reasonwork_exchange/cli/config.py +89 -0
- reasonwork_exchange/cli/login.py +39 -0
- reasonwork_exchange/cli/scan.py +99 -0
- reasonwork_exchange/cli/schedule.py +140 -0
- reasonwork_exchange/cli/sync.py +317 -0
- reasonwork_exchange/cli/update.py +177 -0
- reasonwork_exchange/cli/upload.py +95 -0
- reasonwork_exchange/client.py +273 -0
- reasonwork_exchange/config.py +70 -0
- reasonwork_exchange/discovery.py +59 -0
- reasonwork_exchange/errors.py +59 -0
- reasonwork_exchange/output.py +73 -0
- reasonwork_exchange/state.py +249 -0
- reasonwork_exchange/version_check.py +63 -0
|
@@ -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")
|