benchflow 0.7.2.dev1994__py3-none-any.whl → 0.7.2.dev2000__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.
@@ -20,10 +20,29 @@ from rich.progress import (
20
20
  from rich.table import Table
21
21
  from rich.text import Text
22
22
 
23
- from benchflow.publish.redact import REDACTED
23
+ from benchflow.publish.redact import REDACTED, format_redaction_breakdown
24
24
  from benchflow.publish.traj_capture import StagedFile
25
25
  from benchflow.publish.traj_report import PREVIEW_WORD_LIMIT, TrajectoryReport
26
26
 
27
+ # Redaction-transparency copy shown with the masked-value count. The wording is
28
+ # deliberately honest: redaction is a local structural pass (no agents, no
29
+ # token spend), and the server independently rescans staged artifacts.
30
+ NO_MASKING_NEEDED_COPY = (
31
+ "No secrets or personal identifiers detected — nothing needed masking."
32
+ )
33
+ REDACTION_REASSURANCE_COPY = (
34
+ "Redaction ran locally before anything was staged; "
35
+ "the server independently rescans and rejects any survivor."
36
+ )
37
+
38
+
39
+ def masked_for_you_line(report: TrajectoryReport) -> str:
40
+ """Itemized ``Masked for you`` copy for a report with masked values."""
41
+ breakdown = format_redaction_breakdown(dict(report.masked_categories))
42
+ if not breakdown: # category data unavailable — fall back to the total
43
+ breakdown = f"{report.masked_values} secret-like values"
44
+ return f"{breakdown} — originals never leave this machine"
45
+
27
46
 
28
47
  def render_trajectory_report(report: TrajectoryReport, *, console: Console) -> None:
29
48
  """Render upload facts and a bounded, already-redacted trajectory preview."""
@@ -43,8 +62,17 @@ def render_trajectory_report(report: TrajectoryReport, *, console: Console) -> N
43
62
  "API keys / secrets masked",
44
63
  Text(str(report.masked_values), style="bold green"),
45
64
  )
65
+ if report.masked_values:
66
+ facts.add_row(
67
+ "Masked for you",
68
+ Text(masked_for_you_line(report), style="green"),
69
+ )
70
+ else:
71
+ facts.add_row("Masked for you", Text(NO_MASKING_NEEDED_COPY, style="dim"))
46
72
  facts.add_row("Safe replacement", Text(REDACTED))
47
73
  console.print(Panel(facts, title="Trajectory report", border_style="cyan"))
74
+ if report.masked_values:
75
+ console.print(f"[dim]{REDACTION_REASSURANCE_COPY}[/dim]")
48
76
 
49
77
  if not report.preview:
50
78
  return
benchflow/cli/main.py CHANGED
@@ -1239,11 +1239,25 @@ def eval_view(
1239
1239
  ),
1240
1240
  ),
1241
1241
  ] = False,
1242
+ redaction_summary: Annotated[
1243
+ str | None,
1244
+ typer.Option(
1245
+ "--redaction-summary",
1246
+ help=(
1247
+ "Masked-secret breakdown shown in the --confirm bar, e.g. "
1248
+ "'2 API keys, 1 bearer token' (lift it from "
1249
+ "`bench traj upload PATH --dry-run`). Display-only; no effect "
1250
+ "without --confirm."
1251
+ ),
1252
+ ),
1253
+ ] = None,
1242
1254
  ) -> None:
1243
1255
  """View a trial or session trajectory in the browser."""
1244
1256
  from benchflow.trajectories import viewer
1245
1257
 
1246
- decision = viewer.serve(str(rollout_dir), port, confirm=confirm)
1258
+ decision = viewer.serve(
1259
+ str(rollout_dir), port, confirm=confirm, redaction_summary=redaction_summary
1260
+ )
1247
1261
  # Exit-code contract for --confirm: 0 approved, 3 rejected. 3 is chosen
1248
1262
  # so a rejection never collides with the CLI's existing error exits
1249
1263
  # (1 = errors, 2 = Typer usage errors).
benchflow/cli/traj.py CHANGED
@@ -23,6 +23,7 @@ from benchflow.cli._traj_upload_ui import (
23
23
  render_trajectory_report,
24
24
  upload_progress,
25
25
  )
26
+ from benchflow.publish.redact import format_redaction_breakdown
26
27
  from benchflow.publish.traj_capture import (
27
28
  StagedCapture,
28
29
  default_source_id,
@@ -291,17 +292,23 @@ def register_traj(app: typer.Typer) -> None:
291
292
  def _run_upload(options: _UploadOptions) -> None:
292
293
  prompted = options.path is None
293
294
  path = options.path or _prompt_for_path()
294
- repo_slug: str | None = None
295
+ detected: _DetectedRepo | None = None
295
296
  if options.source_id is not None:
296
297
  source_id = options.source_id
297
298
  else:
298
299
  if options.repo:
299
- repo_slug = _detect_repo_slug(path)
300
- source_id = f"repo/{repo_slug}" if repo_slug else default_source_id(path)
301
- if repo_slug:
302
- # Contributor-visible metadata: surface the tag so private-repo
303
- # sessions can opt out before anything leaves the machine.
304
- console.print(f"Repo: {repo_slug} (use --no-repo to omit)")
300
+ detected = _detect_repo_slug(path)
301
+ source_id = f"repo/{detected.slug}" if detected else default_source_id(path)
302
+ if detected:
303
+ # Contributor-visible metadata: surface the tag and where it came
304
+ # from so private-repo sessions can opt out before anything leaves
305
+ # the machine. The local path stays terminal-only; the uploaded
306
+ # source id is just repo/<owner>/<name>. Plain print keeps the line
307
+ # one physical line (no Rich wrapping) so the path stays selectable.
308
+ print(
309
+ f"Repo: {detected.slug} "
310
+ f"(from session cwd {detected.session_cwd}; use --no-repo to omit)"
311
+ )
305
312
  destination = _resolve_destination(options)
306
313
 
307
314
  with (
@@ -314,6 +321,7 @@ def _run_upload(options: _UploadOptions) -> None:
314
321
  artifacts.files,
315
322
  masked_values=artifacts.redaction_replacements,
316
323
  preview_steps=options.preview_steps,
324
+ masked_categories=artifacts.redaction_categories,
317
325
  )
318
326
  status.stop()
319
327
  render_trajectory_report(report, console=console)
@@ -417,25 +425,35 @@ def _infer_email() -> str:
417
425
  _SESSION_CWD_SCAN_LINES = 50
418
426
 
419
427
 
420
- def _detect_repo_slug(path: Path) -> str | None:
428
+ @dataclass(frozen=True)
429
+ class _DetectedRepo:
430
+ slug: str
431
+ session_cwd: Path
432
+
433
+
434
+ def _detect_repo_slug(path: Path) -> _DetectedRepo | None:
421
435
  """Best-effort ``owner/name`` for the repository the session was about.
422
436
 
423
437
  Reads the working directory the session recorded (Claude events carry a
424
- ``cwd`` field; Codex ``session_meta`` payloads do too), asks that
425
- directory's git for the ``origin`` remote, and falls back to the
426
- invocation directory. Every failure is silent — repo tagging must never
427
- break an upload — and local-path remotes never produce a tag, so no
428
- local absolute path can leak into the manifest.
438
+ ``cwd`` field; Codex ``session_meta`` payloads do too) and asks that
439
+ directory's git for the ``origin`` remote. The trajectory's own recorded
440
+ cwd is the ONLY provenance source: there is deliberately no fallback to
441
+ the upload invocation directory, which mis-attributed sessions recorded
442
+ outside a repo to whatever checkout the contributor happened to upload
443
+ from. No session cwd, a missing directory, or no GitHub remote all mean
444
+ no tag. Every failure is silent — repo tagging must never break an
445
+ upload — and local-path remotes never produce a tag, so no local
446
+ absolute path can leak into the manifest.
429
447
  """
430
- for candidate in (_session_cwd(path), Path.cwd()):
431
- if candidate is None or not candidate.is_dir():
432
- continue
433
- remote = _command_stdout(
434
- "git", "-C", str(candidate), "remote", "get-url", "origin"
435
- )
436
- slug = _repo_slug_from_remote(remote) if remote else None
437
- if slug:
438
- return slug
448
+ session_cwd = _session_cwd(path)
449
+ if session_cwd is None or not session_cwd.is_dir():
450
+ return None
451
+ remote = _command_stdout(
452
+ "git", "-C", str(session_cwd), "remote", "get-url", "origin"
453
+ )
454
+ slug = _repo_slug_from_remote(remote) if remote else None
455
+ if slug:
456
+ return _DetectedRepo(slug=slug, session_cwd=session_cwd)
439
457
  return None
440
458
 
441
459
 
@@ -633,6 +651,13 @@ def _print_dry_run(staged: StagedCapture) -> None:
633
651
  if staged.ignored:
634
652
  console.print(f"Ignored: {escape(', '.join(staged.ignored))}")
635
653
  console.print(f"Redactions: {staged.redaction_replacements}")
654
+ # One plain, greppable line so the upload skill can lift the breakdown into
655
+ # the viewer's confirm bar (bench eval view --redaction-summary "...").
656
+ breakdown = format_redaction_breakdown(dict(staged.artifact_redaction_categories))
657
+ if breakdown:
658
+ print(f"Masked for you: {breakdown}")
659
+ else:
660
+ print("Masked for you: nothing — no secrets detected")
636
661
 
637
662
 
638
663
  def _print_contributor_prompt() -> None:
@@ -4,10 +4,19 @@ from __future__ import annotations
4
4
 
5
5
  import re
6
6
  import shlex
7
+ from collections import Counter
7
8
  from collections.abc import Mapping, Sequence
8
9
  from typing import Any, NamedTuple
9
10
 
10
- from benchflow.trajectories.types import redact_trajectory_text_with_count
11
+ from benchflow.trajectories.types import (
12
+ REDACTION_CATEGORY_API_KEY,
13
+ REDACTION_CATEGORY_BEARER_TOKEN,
14
+ REDACTION_CATEGORY_CREDENTIAL_FIELD,
15
+ REDACTION_CATEGORY_PASSWORD,
16
+ REDACTION_CATEGORY_PRIVATE_KEY,
17
+ REDACTION_CATEGORY_URL_CREDENTIAL,
18
+ redact_trajectory_text_with_categories,
19
+ )
11
20
 
12
21
  REDACTED = "<XXX-benchflow-key-values-XXX>"
13
22
  _CANONICAL_REDACTED = "***REDACTED***"
@@ -67,23 +76,81 @@ COMPACT_SENSITIVE_KEY_SUFFIXES = tuple(
67
76
  class RedactionPattern(NamedTuple):
68
77
  pattern: re.Pattern[str]
69
78
  replacement: str
79
+ category: str
70
80
 
71
81
 
72
82
  VALUE_PATTERNS = (
73
83
  # Google AI Studio's newer token format is not covered by the canonical
74
84
  # trajectory redactor yet.
75
- RedactionPattern(re.compile(r"AQ\.[0-9A-Za-z_-]{20,}"), REDACTED),
85
+ RedactionPattern(
86
+ re.compile(r"AQ\.[0-9A-Za-z_-]{20,}"), REDACTED, REDACTION_CATEGORY_API_KEY
87
+ ),
76
88
  RedactionPattern(
77
89
  re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]{16,}", re.IGNORECASE),
78
90
  f"Bearer {REDACTED}",
91
+ REDACTION_CATEGORY_BEARER_TOKEN,
79
92
  ),
80
93
  )
81
94
 
95
+ # Display order and pluralized labels for the per-category breakdown shown to
96
+ # contributors ("Masked for you: 2 API keys, 1 bearer token"). Keys are the
97
+ # canonical category names from ``benchflow.trajectories.types``; the taxonomy
98
+ # names what the redaction rules actually detect — nothing more.
99
+ REDACTION_CATEGORY_LABELS: tuple[tuple[str, str], ...] = (
100
+ (REDACTION_CATEGORY_API_KEY, "API keys"),
101
+ (REDACTION_CATEGORY_BEARER_TOKEN, "bearer tokens"),
102
+ (REDACTION_CATEGORY_PRIVATE_KEY, "private key blocks"),
103
+ (REDACTION_CATEGORY_PASSWORD, "passwords"),
104
+ (REDACTION_CATEGORY_URL_CREDENTIAL, "URL credentials"),
105
+ (REDACTION_CATEGORY_CREDENTIAL_FIELD, "credential-bearing field values"),
106
+ )
107
+
108
+
109
+ def redaction_breakdown(counts: Mapping[str, int]) -> tuple[tuple[str, int], ...]:
110
+ """Return non-zero category counts in canonical display order."""
111
+ ordered = [
112
+ (category, counts[category])
113
+ for category, _ in REDACTION_CATEGORY_LABELS
114
+ if counts.get(category)
115
+ ]
116
+ # Unknown categories cannot arise from this module's own accumulation, but
117
+ # persisted/forwarded counts must never be dropped silently.
118
+ ordered.extend(
119
+ (category, count)
120
+ for category, count in counts.items()
121
+ if count and category not in dict(REDACTION_CATEGORY_LABELS)
122
+ )
123
+ return tuple(ordered)
124
+
125
+
126
+ def format_redaction_breakdown(counts: Mapping[str, int]) -> str:
127
+ """Render category counts as prose, e.g. ``2 API keys, 1 bearer token``."""
128
+ plurals = dict(REDACTION_CATEGORY_LABELS)
129
+ parts = []
130
+ for category, count in redaction_breakdown(counts):
131
+ label = category if count == 1 else plurals.get(category, f"{category}s")
132
+ parts.append(f"{count} {label}")
133
+ return ", ".join(parts)
82
134
 
83
- def redact_value(value: Any, *, field_name: str | None = None) -> tuple[Any, int]:
84
- """Return a structurally redacted JSON value and replacement count."""
135
+
136
+ def redact_value(
137
+ value: Any,
138
+ *,
139
+ field_name: str | None = None,
140
+ categories: Counter[str] | None = None,
141
+ ) -> tuple[Any, int]:
142
+ """Return a structurally redacted JSON value and replacement count.
143
+
144
+ When *categories* is given, every replacement also increments the matching
145
+ per-category counter (see ``REDACTION_CATEGORY_LABELS``); the counter's
146
+ total always equals the returned replacement count.
147
+ """
85
148
  if field_name is not None and _is_sensitive_key(field_name):
86
- return (value, 0) if _is_redacted_value(value) else (REDACTED, 1)
149
+ if _is_redacted_value(value):
150
+ return value, 0
151
+ if categories is not None:
152
+ categories[_field_category(field_name)] += 1
153
+ return REDACTED, 1
87
154
 
88
155
  if isinstance(value, Mapping):
89
156
  redacted: dict[Any, Any] = {}
@@ -104,7 +171,7 @@ def redact_value(value: Any, *, field_name: str | None = None) -> tuple[Any, int
104
171
  for key, item in value.items():
105
172
  clean_key = key
106
173
  if isinstance(key, str):
107
- _, key_replacements = _redact_text(key)
174
+ _, key_replacements = _redact_text(key, categories=categories)
108
175
  if key_replacements:
109
176
  secret_key_index += 1
110
177
  clean_key = f"***REDACTED_KEY_{secret_key_index}***"
@@ -122,73 +189,102 @@ def redact_value(value: Any, *, field_name: str | None = None) -> tuple[Any, int
122
189
  if isinstance(key, str)
123
190
  else None
124
191
  ),
192
+ categories=categories,
125
193
  )
126
194
  redacted[clean_key] = clean
127
195
  replacements += count
128
196
  return redacted, replacements
129
197
 
130
198
  if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
131
- return _redact_argv(value, redact_other_values=True)
199
+ return _redact_argv(value, redact_other_values=True, categories=categories)
132
200
 
133
201
  if not isinstance(value, str):
134
202
  return value, 0
135
203
 
136
- return _redact_text(value)
204
+ return _redact_text(value, categories=categories)
137
205
 
138
206
 
139
- def redact_value_to_stability(value: Any) -> tuple[Any, int]:
207
+ def redact_value_to_stability(
208
+ value: Any, *, categories: Counter[str] | None = None
209
+ ) -> tuple[Any, int]:
140
210
  """Redact until another server-side pass would make no replacements."""
141
211
  redacted = value
142
212
  replacements = 0
143
213
  for _ in range(_MAX_REDACTION_PASSES):
144
- redacted, count = redact_value(redacted)
214
+ redacted, count = redact_value(redacted, categories=categories)
145
215
  replacements += count
146
216
  if count == 0:
147
217
  return redacted, replacements
148
218
  raise ValueError("trajectory secret redaction did not converge")
149
219
 
150
220
 
151
- def _redact_text(value: str) -> tuple[str, int]:
221
+ def _redact_text(
222
+ value: str, *, categories: Counter[str] | None = None
223
+ ) -> tuple[str, int]:
152
224
  # The canonical carrier patterns intentionally ignore their asterisk marker.
153
225
  # Protect our public-upload marker with that value during the scan so a second
154
226
  # client/server pass is idempotent even inside text such as ``API_KEY=...``.
155
227
  protected = value.replace(REDACTED, _CANONICAL_REDACTED)
156
- redacted_text, replacements = redact_trajectory_text_with_count(protected)
228
+ redacted_text, text_categories = redact_trajectory_text_with_categories(protected)
229
+ replacements = sum(text_categories.values())
230
+ if categories is not None:
231
+ categories.update(text_categories)
157
232
  redacted_text = redacted_text.replace(_CANONICAL_REDACTED, REDACTED)
158
- for pattern, replacement in VALUE_PATTERNS:
233
+ for pattern, replacement, category in VALUE_PATTERNS:
159
234
  redacted_text, count = pattern.subn(replacement, redacted_text)
160
235
  replacements += count
161
- cli_redacted, cli_replacements = _redact_cli_text(redacted_text)
236
+ if count and categories is not None:
237
+ categories[category] += count
238
+ cli_redacted, cli_replacements = _redact_cli_text(
239
+ redacted_text, categories=categories
240
+ )
162
241
  redacted_text = cli_redacted
163
242
  replacements += cli_replacements
164
243
  return redacted_text, replacements
165
244
 
166
245
 
167
- def _redact_cli_text(value: str) -> tuple[str, int]:
246
+ def _redact_cli_text(
247
+ value: str, *, categories: Counter[str] | None = None
248
+ ) -> tuple[str, int]:
168
249
  if "--" not in value:
169
250
  return value, 0
170
251
  try:
171
252
  argv = shlex.split(value)
172
253
  except ValueError:
173
254
  return value, 0
174
- redacted, replacements = _redact_argv(argv, redact_other_values=False)
175
- return (shlex.join(redacted), replacements) if replacements else (value, 0)
255
+ # Count categories only for the accepted result: a zero-replacement parse
256
+ # returns the original text, so nothing may be attributed for it.
257
+ argv_categories: Counter[str] | None = None if categories is None else Counter()
258
+ redacted, replacements = _redact_argv(
259
+ argv, redact_other_values=False, categories=argv_categories
260
+ )
261
+ if not replacements:
262
+ return value, 0
263
+ if categories is not None and argv_categories is not None:
264
+ categories.update(argv_categories)
265
+ return shlex.join(redacted), replacements
176
266
 
177
267
 
178
268
  def _redact_argv(
179
- values: Sequence[Any], *, redact_other_values: bool
269
+ values: Sequence[Any],
270
+ *,
271
+ redact_other_values: bool,
272
+ categories: Counter[str] | None = None,
180
273
  ) -> tuple[list[Any], int]:
181
274
  redacted: list[Any] = []
182
275
  replacements = 0
183
- redact_next = False
276
+ sensitive_option: str | None = None
184
277
  for item in values:
185
- if redact_next:
278
+ if sensitive_option is not None:
279
+ option = sensitive_option
280
+ sensitive_option = None
186
281
  if _is_redacted_value(item):
187
282
  redacted.append(item)
188
283
  else:
189
284
  redacted.append(REDACTED)
190
285
  replacements += 1
191
- redact_next = False
286
+ if categories is not None:
287
+ categories[_field_category(option)] += 1
192
288
  continue
193
289
 
194
290
  if isinstance(item, str) and item.startswith("-"):
@@ -200,13 +296,16 @@ def _redact_argv(
200
296
  else:
201
297
  redacted.append(f"{option}={REDACTED}")
202
298
  replacements += 1
299
+ if categories is not None:
300
+ categories[_field_category(option)] += 1
203
301
  else:
204
302
  redacted.append(item)
205
- redact_next = not separator
303
+ if not separator:
304
+ sensitive_option = option
206
305
  continue
207
306
 
208
307
  if redact_other_values:
209
- clean, count = redact_value(item)
308
+ clean, count = redact_value(item, categories=categories)
210
309
  redacted.append(clean)
211
310
  replacements += count
212
311
  else:
@@ -220,6 +319,22 @@ def _is_redacted_value(value: Any) -> bool:
220
319
  )
221
320
 
222
321
 
322
+ def _field_category(field_name: str) -> str:
323
+ """Categorize a masked value by the credential-bearing name that matched.
324
+
325
+ Structural redaction fires on the field/option NAME, so the name is the
326
+ only honest signal for what kind of secret the value was.
327
+ """
328
+ compact = _normalize_key(field_name).replace("_", "")
329
+ if compact.endswith(("password", "passwd")):
330
+ return REDACTION_CATEGORY_PASSWORD
331
+ if compact.endswith("apikey"):
332
+ return REDACTION_CATEGORY_API_KEY
333
+ if compact.endswith("authorization") or "bearertoken" in compact:
334
+ return REDACTION_CATEGORY_BEARER_TOKEN
335
+ return REDACTION_CATEGORY_CREDENTIAL_FIELD
336
+
337
+
223
338
  def _is_sensitive_key(field_name: str) -> bool:
224
339
  normalized = _normalize_key(field_name)
225
340
  return (
@@ -7,6 +7,7 @@ import json
7
7
  import re
8
8
  import shutil
9
9
  import tempfile
10
+ from collections import Counter
10
11
  from collections.abc import Iterator, Mapping
11
12
  from contextlib import contextmanager
12
13
  from dataclasses import dataclass
@@ -15,7 +16,11 @@ from pathlib import Path
15
16
  from typing import Any, Never
16
17
 
17
18
  from benchflow import __version__
18
- from benchflow.publish.redact import redact_value, redact_value_to_stability
19
+ from benchflow.publish.redact import (
20
+ redact_value,
21
+ redact_value_to_stability,
22
+ redaction_breakdown,
23
+ )
19
24
 
20
25
  MAX_FILE_BYTES = 128 * 1024**2
21
26
  MAX_ARTIFACTS = 8
@@ -81,6 +86,10 @@ class StagedTrajectoryArtifacts:
81
86
  _metadata_dir: Path | None
82
87
  _staging_dir: Path
83
88
  _redact: bool
89
+ # Per-category artifact replacement counts in display order (display-only:
90
+ # the manifest/server contract stays untouched). Sums to
91
+ # ``redaction_replacements``.
92
+ redaction_categories: tuple[tuple[str, int], ...] = ()
84
93
 
85
94
 
86
95
  @dataclass(frozen=True)
@@ -92,6 +101,8 @@ class StagedCapture:
92
101
  ignored: tuple[str, ...]
93
102
  artifact_redaction_replacements: int
94
103
  redaction_replacements: int
104
+ # Display-only per-category counts for the artifact replacements.
105
+ artifact_redaction_categories: tuple[tuple[str, int], ...] = ()
95
106
 
96
107
 
97
108
  @dataclass(frozen=True)
@@ -199,12 +210,15 @@ def stage_trajectory_artifacts(
199
210
  staging_dir = Path(temp_name)
200
211
  payloads: list[StagedFile] = []
201
212
  replacement_count = 0
213
+ replacement_categories: Counter[str] = Counter()
202
214
  for source in resolved.files:
203
215
  relname = validate_artifact_name(f"trajectory/{source.name}")
204
216
  target = staging_dir / relname
205
217
  target.parent.mkdir(parents=True, exist_ok=True)
206
218
  if redact:
207
- replacements = _redact_jsonl(source, target)
219
+ replacements = _redact_jsonl(
220
+ source, target, categories=replacement_categories
221
+ )
208
222
  replacement_count += replacements
209
223
  else:
210
224
  shutil.copyfile(source, target)
@@ -240,6 +254,7 @@ def stage_trajectory_artifacts(
240
254
  _metadata_dir=resolved.metadata_dir,
241
255
  _staging_dir=staging_dir,
242
256
  _redact=redact,
257
+ redaction_categories=redaction_breakdown(replacement_categories),
243
258
  )
244
259
 
245
260
 
@@ -305,6 +320,7 @@ def finalize_trajectory_capture(
305
320
  ignored=artifacts.ignored,
306
321
  artifact_redaction_replacements=artifacts.redaction_replacements,
307
322
  redaction_replacements=replacement_count,
323
+ artifact_redaction_categories=artifacts.redaction_categories,
308
324
  )
309
325
 
310
326
 
@@ -430,7 +446,9 @@ def _validate_jsonl(path: Path) -> None:
430
446
  raise ValueError(f"trajectory JSONL has no records: {path}")
431
447
 
432
448
 
433
- def _redact_jsonl(source: Path, target: Path) -> int:
449
+ def _redact_jsonl(
450
+ source: Path, target: Path, *, categories: Counter[str] | None = None
451
+ ) -> int:
434
452
  replacements = 0
435
453
  with (
436
454
  source.open(encoding="utf-8", newline="") as input_stream,
@@ -443,7 +461,9 @@ def _redact_jsonl(source: Path, target: Path) -> int:
443
461
  continue
444
462
  value = strict_json_loads(body)
445
463
  try:
446
- redacted, count = redact_value_to_stability(value)
464
+ redacted, count = redact_value_to_stability(
465
+ value, categories=categories
466
+ )
447
467
  except RecursionError as exc: # defense in depth after complexity gate
448
468
  raise ValueError(
449
469
  f"{source}: line {line_number}: trajectory JSONL nesting "
@@ -54,9 +54,20 @@ class TrajectoryReport:
54
54
  created_at_source: str
55
55
  masked_values: int
56
56
  preview: tuple[TrajectoryPreviewStep, ...]
57
+ # Per-category masked-value counts, display order, summing to
58
+ # ``masked_values``. Display-only: the manifest ``trajectory_report``
59
+ # contract (``TrajectoryReportInfo``, ``extra="forbid"``, exact
60
+ # recompute-equality on the server) must not gain fields, so
61
+ # ``as_manifest_metadata`` deliberately excludes this.
62
+ masked_categories: tuple[tuple[str, int], ...] = ()
57
63
 
58
64
  def as_manifest_metadata(self) -> dict[str, Any]:
59
- """Serialize every displayed report field for the upload manifest."""
65
+ """Serialize every displayed report field for the upload manifest.
66
+
67
+ ``masked_categories`` is intentionally absent: the server validates
68
+ ``trajectory_report`` with ``extra="forbid"`` and an exact equality
69
+ check against its own recomputation, so new fields would be rejected.
70
+ """
60
71
  return {
61
72
  "primary_file": self.primary_file,
62
73
  "format": self.format.value,
@@ -133,6 +144,7 @@ def build_trajectory_report(
133
144
  *,
134
145
  masked_values: int,
135
146
  preview_steps: int = DEFAULT_PREVIEW_STEPS,
147
+ masked_categories: tuple[tuple[str, int], ...] = (),
136
148
  ) -> TrajectoryReport:
137
149
  """Summarize one canonical trajectory view from staged, redacted artifacts."""
138
150
  if not artifacts:
@@ -183,6 +195,7 @@ def build_trajectory_report(
183
195
  created_at_source=created_at_source,
184
196
  masked_values=masked_values,
185
197
  preview=tuple(analysis.preview),
198
+ masked_categories=masked_categories,
186
199
  )
187
200
 
188
201
 
@@ -179,6 +179,18 @@ def _exchange_token_usage(exchange: "LLMExchange") -> TokenUsage:
179
179
  )
180
180
 
181
181
 
182
+ # Human-facing redaction categories. Every canonical pattern below is tagged
183
+ # with the kind of secret its rule actually detects, so the upload preview can
184
+ # tell contributors WHAT was masked ("2 API keys, 1 bearer token") instead of
185
+ # only how many values. The taxonomy names the real rules — there is no
186
+ # PII/email detection, so no such category exists.
187
+ REDACTION_CATEGORY_API_KEY = "API key"
188
+ REDACTION_CATEGORY_BEARER_TOKEN = "bearer token"
189
+ REDACTION_CATEGORY_PRIVATE_KEY = "private key block"
190
+ REDACTION_CATEGORY_PASSWORD = "password"
191
+ REDACTION_CATEGORY_URL_CREDENTIAL = "URL credential"
192
+ REDACTION_CATEGORY_CREDENTIAL_FIELD = "credential-bearing field value"
193
+
182
194
  # Quote atom for the header/key-value carriers below: a run of 0-8 optional
183
195
  # backslashes followed by an optional quote. This makes every carrier fire on raw
184
196
  # text (``"k": "v"``), Python dict-repr (``'k': 'v'``), AND json.dumps-escaped
@@ -202,7 +214,7 @@ _SECVAL = r"[^\"'\s,}\\*&]+"
202
214
  # ``{0,64}`` makes each match attempt O(1) → overall O(n) (#830 ReDoS fix).
203
215
  _NAME = r"[A-Za-z0-9_]{0,64}"
204
216
 
205
- _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
217
+ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str, str]] = [
206
218
  # PEM private-key blocks (GCP/Vertex service-account JSON, RSA keys). Redact
207
219
  # the whole armored block incl. the base64 body; the carriers below stop at
208
220
  # the first space and would leave the key material. Lazy + length-capped body
@@ -214,17 +226,23 @@ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
214
226
  r"-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----"
215
227
  ),
216
228
  "***REDACTED***",
229
+ REDACTION_CATEGORY_PRIVATE_KEY,
217
230
  ),
218
231
  # --- Token families: redacted WHOLE (prefix included) so the v0.5 leak audit
219
232
  # greps (``AIzaSy``/``dtn_``…) see no live-key shape (#537/#585). ---
220
233
  # Anthropic: sk-ant-api03-...
221
- (re.compile(r"sk-ant-[a-zA-Z0-9_-]{12,}"), "***REDACTED***"),
234
+ (
235
+ re.compile(r"sk-ant-[a-zA-Z0-9_-]{12,}"),
236
+ "***REDACTED***",
237
+ REDACTION_CATEGORY_API_KEY,
238
+ ),
222
239
  # BenchFlow proxy master key (sk-benchflow-<token_urlsafe(24)>), OpenAI service
223
240
  # account (sk-svcacct-), OpenRouter (sk-or-v1-): rare labels that won't appear
224
241
  # in a normal kebab slug, so the hyphen-permitting entropy class is safe here.
225
242
  (
226
243
  re.compile(r"sk-(?:benchflow|svcacct|or-v1)-[A-Za-z0-9_-]{12,}"),
227
244
  "***REDACTED***",
245
+ REDACTION_CATEGORY_API_KEY,
228
246
  ),
229
247
  # OpenAI org-scoped sk-proj-/sk-admin-: `proj`/`admin` ARE common identifier
230
248
  # words, so a bare hyphen class would redact kebab slugs like
@@ -236,33 +254,58 @@ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
236
254
  r"sk-(?:proj|admin)-(?=[A-Za-z0-9_-]{0,200}[A-Z])[A-Za-z0-9_-]{12,}"
237
255
  ),
238
256
  "***REDACTED***",
257
+ REDACTION_CATEGORY_API_KEY,
239
258
  ),
240
259
  # OpenAI / generic sk- (alphanumeric only — widening to include `-` would
241
260
  # match common slugs like `task-sk-us-east-1-...`)
242
- (re.compile(r"sk-[a-zA-Z0-9]{12,}"), "***REDACTED***"),
261
+ (re.compile(r"sk-[a-zA-Z0-9]{12,}"), "***REDACTED***", REDACTION_CATEGORY_API_KEY),
243
262
  # Google AI / Gemini: AIzaSy... (≥20 char suffix avoids matching `AIzaSy`
244
263
  # alone). Prefix is redacted too so the audit grep for `AIzaSy` is clean.
245
- (re.compile(r"AIzaSy[A-Za-z0-9_-]{20,}"), "***REDACTED***"),
264
+ (
265
+ re.compile(r"AIzaSy[A-Za-z0-9_-]{20,}"),
266
+ "***REDACTED***",
267
+ REDACTION_CATEGORY_API_KEY,
268
+ ),
246
269
  # AWS access keys: AKIA/ASIA + exactly 16 chars; length anchor avoids
247
270
  # matching English words like "ASIAPACIFIC".
248
- (re.compile(r"(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Z0-9])"), "***REDACTED***"),
271
+ (
272
+ re.compile(r"(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Z0-9])"),
273
+ "***REDACTED***",
274
+ REDACTION_CATEGORY_API_KEY,
275
+ ),
249
276
  # Daytona SDK tokens: dtn_... — ≥16 char suffix avoids short ids (`dtn_v2`).
250
- (re.compile(r"dtn_[A-Za-z0-9_]{16,}"), "***REDACTED***"),
277
+ (
278
+ re.compile(r"dtn_[A-Za-z0-9_]{16,}"),
279
+ "***REDACTED***",
280
+ REDACTION_CATEGORY_API_KEY,
281
+ ),
251
282
  # GitHub tokens: PATs / OAuth / app / refresh (ghp_/gho_/ghu_/ghs_/ghr_) and
252
283
  # fine-grained PATs (github_pat_...). ≥20 char suffix avoids short slugs.
253
- (re.compile(r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}"), "***REDACTED***"),
254
- (re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), "***REDACTED***"),
284
+ (
285
+ re.compile(r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}"),
286
+ "***REDACTED***",
287
+ REDACTION_CATEGORY_API_KEY,
288
+ ),
289
+ (
290
+ re.compile(r"github_pat_[A-Za-z0-9_]{20,}"),
291
+ "***REDACTED***",
292
+ REDACTION_CATEGORY_API_KEY,
293
+ ),
255
294
  # Slack tokens: bot/user/app/refresh/legacy (xoxb-/xoxp-/xoxa-/xoxr-/xoxs-).
256
- (re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), "***REDACTED***"),
295
+ (
296
+ re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"),
297
+ "***REDACTED***",
298
+ REDACTION_CATEGORY_API_KEY,
299
+ ),
257
300
  # Other provider key families with distinctive prefixes — caught here for the
258
301
  # bare-token-in-prose form that no NAME=value carrier sees (a provider
259
302
  # exception that echoes the key). Groq gsk_, xAI xai-, Replicate r8_,
260
303
  # HuggingFace hf_, Fireworks fw_. ≥20-char anchors avoid short ids (#830).
261
- (re.compile(r"gsk_[A-Za-z0-9]{20,}"), "***REDACTED***"),
262
- (re.compile(r"xai-[A-Za-z0-9]{20,}"), "***REDACTED***"),
263
- (re.compile(r"r8_[A-Za-z0-9]{20,}"), "***REDACTED***"),
264
- (re.compile(r"hf_[A-Za-z0-9]{20,}"), "***REDACTED***"),
265
- (re.compile(r"fw_[A-Za-z0-9]{20,}"), "***REDACTED***"),
304
+ (re.compile(r"gsk_[A-Za-z0-9]{20,}"), "***REDACTED***", REDACTION_CATEGORY_API_KEY),
305
+ (re.compile(r"xai-[A-Za-z0-9]{20,}"), "***REDACTED***", REDACTION_CATEGORY_API_KEY),
306
+ (re.compile(r"r8_[A-Za-z0-9]{20,}"), "***REDACTED***", REDACTION_CATEGORY_API_KEY),
307
+ (re.compile(r"hf_[A-Za-z0-9]{20,}"), "***REDACTED***", REDACTION_CATEGORY_API_KEY),
308
+ (re.compile(r"fw_[A-Za-z0-9]{20,}"), "***REDACTED***", REDACTION_CATEGORY_API_KEY),
266
309
  # JSON Web Tokens (session/bearer creds): three base64url segments split by
267
310
  # dots. The ``eyJ`` prefix is base64url of ``{"`` — a JWT header. The 3rd
268
311
  # (signature) segment requires ≥20 chars (real HS256/RS256 sigs are 43+) so
@@ -275,6 +318,7 @@ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
275
318
  r"eyJ[A-Za-z0-9_-]{10,512}\.[A-Za-z0-9_-]{6,512}\.[A-Za-z0-9_-]{20,512}"
276
319
  ),
277
320
  "***REDACTED***",
321
+ REDACTION_CATEGORY_BEARER_TOKEN,
278
322
  ),
279
323
  # --- URL credentials ---
280
324
  # Credentials in a URL's userinfo (scheme://user:pass@host) for KNOWN
@@ -293,6 +337,7 @@ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
293
337
  r"[^\s/?#:@]{0,256}:[^\s/?#]{0,256}@"
294
338
  ),
295
339
  r"\1***REDACTED***@",
340
+ REDACTION_CATEGORY_URL_CREDENTIAL,
296
341
  ),
297
342
  # Secrets carried as URL query params. Curated EXACT param names (anchored to
298
343
  # `?`/`&` with `=` right after the name) so non-secret params (`?page=`,
@@ -317,6 +362,7 @@ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
317
362
  re.IGNORECASE,
318
363
  ),
319
364
  r"\1***REDACTED***",
365
+ REDACTION_CATEGORY_URL_CREDENTIAL,
320
366
  ),
321
367
  # Azure SAS `?sig=<token>` — bare `sig` is excluded from the curated list above
322
368
  # (a `?sig=v2` scheme-version flag is a common non-secret), so gate on a
@@ -326,6 +372,7 @@ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
326
372
  (
327
373
  re.compile(r"([?&]sig=)[^&\s\"'*]{16,}", re.IGNORECASE),
328
374
  r"\1***REDACTED***",
375
+ REDACTION_CATEGORY_URL_CREDENTIAL,
329
376
  ),
330
377
  # --- Header / key-value secret carriers ---
331
378
  # Match `name: value` / `name=value` in raw, JSON, Python dict-repr
@@ -340,6 +387,7 @@ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
340
387
  re.IGNORECASE,
341
388
  ),
342
389
  r"\1***REDACTED***",
390
+ REDACTION_CATEGORY_API_KEY,
343
391
  ),
344
392
  # Underscore form `api_key`. No leading boundary: namespaced env dumps like
345
393
  # `GEMINI_API_KEY=secret` / JSON keys such as `"openai_api_key"` must redact
@@ -349,6 +397,7 @@ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
349
397
  rf"({_ESCQ}api_key{_ESCQ}\s*[:=]\s*{_ESCQ}){_SECVAL}", re.IGNORECASE
350
398
  ),
351
399
  r"\1***REDACTED***",
400
+ REDACTION_CATEGORY_API_KEY,
352
401
  ),
353
402
  # `master_key`/`master-key` and `private_key`/`private-key` carriers — the
354
403
  # BenchFlow proxy master key and GCP/Vertex service-account private-key field
@@ -359,6 +408,7 @@ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
359
408
  re.IGNORECASE,
360
409
  ),
361
410
  r"\1***REDACTED***",
411
+ REDACTION_CATEGORY_CREDENTIAL_FIELD,
362
412
  ),
363
413
  # Bearer-token env vars whose NAME has BEARER_TOKEN in the MIDDLE, e.g.
364
414
  # `AWS_BEARER_TOKEN_BEDROCK=` — the secret-suffix rule below anchors its marker
@@ -371,6 +421,7 @@ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
371
421
  re.IGNORECASE,
372
422
  ),
373
423
  r"\1***REDACTED***",
424
+ REDACTION_CATEGORY_BEARER_TOKEN,
374
425
  ),
375
426
  # Generic secret-NAME-suffix carriers: names ending in TOKEN/SECRET/PASSWORD or
376
427
  # a specific *secret* key suffix (ACCESS_KEY/SECRET_KEY/ACCOUNT_KEY/…). The KEY
@@ -388,6 +439,7 @@ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
388
439
  re.IGNORECASE,
389
440
  ),
390
441
  r"\1***REDACTED***",
442
+ REDACTION_CATEGORY_CREDENTIAL_FIELD,
391
443
  ),
392
444
  # authorization header WITH a scheme (Bearer/Token/Basic/…): keep scheme.
393
445
  (
@@ -397,6 +449,7 @@ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
397
449
  re.IGNORECASE,
398
450
  ),
399
451
  r"\1***REDACTED***",
452
+ REDACTION_CATEGORY_BEARER_TOKEN,
400
453
  ),
401
454
  # authorization header with a bare value (no recognized scheme). The negative
402
455
  # lookahead skips scheme-prefixed values already handled above, so
@@ -409,6 +462,7 @@ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
409
462
  re.IGNORECASE,
410
463
  ),
411
464
  r"\1***REDACTED***",
465
+ REDACTION_CATEGORY_BEARER_TOKEN,
412
466
  ),
413
467
  ]
414
468
 
@@ -428,11 +482,25 @@ def redact_trajectory_text(text: str) -> str:
428
482
 
429
483
  def redact_trajectory_text_with_count(text: str) -> tuple[str, int]:
430
484
  """Apply the canonical patterns and report how many matches were replaced."""
431
- replacements = 0
432
- for pattern, replacement in _REDACTION_PATTERNS:
485
+ text, categories = redact_trajectory_text_with_categories(text)
486
+ return text, sum(categories.values())
487
+
488
+
489
+ def redact_trajectory_text_with_categories(text: str) -> tuple[str, dict[str, int]]:
490
+ """Apply the canonical patterns and report replacements per category.
491
+
492
+ Categories name the kind of secret each rule actually detects (API key,
493
+ bearer token, private key block, URL credential, credential-bearing field
494
+ value) so upload previews can itemize what was masked without inventing
495
+ detection that does not exist. The total replacement count is the sum of
496
+ the returned values.
497
+ """
498
+ categories: dict[str, int] = {}
499
+ for pattern, replacement, category in _REDACTION_PATTERNS:
433
500
  text, count = pattern.subn(replacement, text)
434
- replacements += count
435
- return text, replacements
501
+ if count:
502
+ categories[category] = categories.get(category, 0) + count
503
+ return text, categories
436
504
 
437
505
 
438
506
  def redact_trajectory_obj(obj: Any) -> Any:
@@ -97,9 +97,11 @@ body { padding-bottom: 104px; }
97
97
  .confirm-btn.approve:hover { background: #262626; }
98
98
  .confirm-btn.reject { background: var(--card); color: var(--ink); border: 1px solid var(--rule-strong); }
99
99
  .confirm-btn.reject:hover { background: var(--secondary); }
100
+ .confirm-note { flex-basis: 100%; font-size: 12.5px; color: var(--muted); }
100
101
  </style>
101
102
  <div class="confirm-bar">
102
103
  <div class="confirm-inner" id="confirm-inner">
104
+ <!--BENCHFLOW-REDACTION-NOTE-->
103
105
  <span class="confirm-question">Submit this trajectory to the BenchFlow eval prize?</span>
104
106
  <div class="confirm-actions">
105
107
  <button class="confirm-btn approve" onclick="__benchDecide('approve')">Approve &amp; submit</button>
@@ -124,11 +126,33 @@ async function __benchDecide(choice) {
124
126
  """
125
127
 
126
128
 
127
- def _inject_confirm_bar(page: str) -> str:
129
+ _REDACTION_NOTE_MARKER = "<!--BENCHFLOW-REDACTION-NOTE-->"
130
+
131
+
132
+ def _confirm_bar_html(redaction_summary: str | None) -> str:
133
+ """Confirm-bar snippet, optionally carrying the redaction-summary note.
134
+
135
+ The note is presentation-only text the caller composed (the upload skill
136
+ lifts it from ``bench traj upload --dry-run``); the viewer itself never
137
+ redacts, so without a summary the bar is byte-identical to the plain
138
+ ``--confirm`` bar.
139
+ """
140
+ note = ""
141
+ if redaction_summary:
142
+ note = (
143
+ '<div class="confirm-note">Before upload, BenchFlow masks: '
144
+ f"{html.escape(redaction_summary)}. "
145
+ "Originals never leave this machine.</div>"
146
+ )
147
+ return _CONFIRM_BAR_HTML.replace(_REDACTION_NOTE_MARKER, note, 1)
148
+
149
+
150
+ def _inject_confirm_bar(page: str, redaction_summary: str | None = None) -> str:
128
151
  """Append the confirm bar just before </body> (or at the end as fallback)."""
152
+ bar = _confirm_bar_html(redaction_summary)
129
153
  if "</body>" in page:
130
- return page.replace("</body>", f"{_CONFIRM_BAR_HTML}</body>", 1)
131
- return page + _CONFIRM_BAR_HTML
154
+ return page.replace("</body>", f"{bar}</body>", 1)
155
+ return page + bar
132
156
 
133
157
 
134
158
  # Small BenchFlow wordmark header (inline SVG logo from the site's icon set —
@@ -407,26 +431,18 @@ def render_rollout(rollout_dir: Path, prompts: list[str] | None = None) -> str:
407
431
  while len(prompts) < len(turn_files):
408
432
  prompts.append("")
409
433
 
410
- first_events = _parse_jsonl(turn_files[0].read_text())
411
- sys_event = next((e for e in first_events if e.get("type") == "system"), {})
412
- total_cost = 0
413
- total_turns_count = 0
414
-
434
+ all_events: list[dict] = []
415
435
  all_blocks = []
416
436
  for i, tf in enumerate(turn_files):
417
437
  events = _parse_jsonl(tf.read_text())
438
+ all_events.extend(events)
418
439
  all_blocks.append(render_turn(events, i + 1, prompts[i]))
419
- for e in events:
420
- if e.get("type") == "result":
421
- total_cost += e.get("total_cost_usd", 0)
422
- total_turns_count += e.get("num_turns", 0)
423
-
424
- # `or "?"` (not just a .get default): a present-but-null value in the
425
- # stream-json (e.g. "session_id": null) bypasses the default and would crash
426
- # html.escape() / the [:16] slice below with a raw TypeError.
427
- session_id = str(sys_event.get("session_id") or "?")
428
- model = str(sys_event.get("model") or "?")
429
- version = str(sys_event.get("claude_code_version") or "?")
440
+
441
+ badges = _stream_header_badges(all_events)
442
+ badges.append(("turns", str(len(turn_files))))
443
+ total_cost = _result_cost(all_events)
444
+ if total_cost is not None:
445
+ badges.append(("total cost", f"${total_cost:.4f}"))
430
446
 
431
447
  return f"""<!DOCTYPE html>
432
448
  <html>
@@ -440,14 +456,7 @@ def render_rollout(rollout_dir: Path, prompts: list[str] | None = None) -> str:
440
456
  <div class="header">
441
457
  {_WORDMARK_HTML}
442
458
  <h1>{html.escape(rollout_dir.name)}</h1>
443
- <div class="meta">
444
- <span>model: {html.escape(model)}</span>
445
- <span>session: {html.escape(session_id[:16])}...</span>
446
- <span>claude code: {html.escape(version)}</span>
447
- <span>turns: {len(turn_files)}</span>
448
- <span>total cost: ${total_cost:.4f}</span>
449
- </div>
450
- </div>
459
+ {_meta_badges_html(badges)}</div>
451
460
  {_join_with_divider(all_blocks)}
452
461
  </body>
453
462
  </html>"""
@@ -648,18 +657,89 @@ def render_jsonl_file(path: Path) -> str:
648
657
  body = render_turn(events, 1, "")
649
658
  if not body.strip():
650
659
  return _NO_TRAJECTORIES_HTML
651
- return _stream_json_page(path.name, events, [body])
660
+ return _stream_json_page(path.name, events, [body], session_fallback=path.stem)
661
+
662
+
663
+ def _stream_header_badges(
664
+ events: list[dict], *, session_fallback: str | None = None
665
+ ) -> list[tuple[str, str]]:
666
+ """Header badges derivable from what the stream actually contains.
667
+
668
+ ``claude -p`` stream-json carries a ``type: system`` init event
669
+ (``session_id`` / ``model`` / ``claude_code_version``); real ``~/.claude``
670
+ session files don't — their metadata lives per event (``sessionId``,
671
+ ``version``) and on assistant events (``message.model``). Pull from
672
+ whichever is present and omit anything unknown: a header of ``?`` badges
673
+ at the approve moment reads as a broken viewer, not as missing data.
674
+ Truthiness (not ``.get`` defaults) also swallows present-but-null values
675
+ like ``"session_id": null``, which previously needed an explicit guard to
676
+ avoid a TypeError.
677
+ """
678
+ sys_event = next((e for e in events if e.get("type") == "system"), {})
652
679
 
680
+ def _first(values) -> object | None:
681
+ return next((value for value in values if value), None)
653
682
 
654
- def _stream_json_page(title: str, events: list[dict], turn_blocks: list[str]) -> str:
655
- sys_event = next((e for e in events if e.get("type") == "system"), {})
656
- total_cost = 0.0
683
+ model = sys_event.get("model") or _first(
684
+ event["message"].get("model")
685
+ for event in events
686
+ if event.get("type") == "assistant" and isinstance(event.get("message"), dict)
687
+ )
688
+ session_id = (
689
+ sys_event.get("session_id")
690
+ or _first(event.get("sessionId") for event in events)
691
+ or session_fallback
692
+ )
693
+ version = sys_event.get("claude_code_version") or _first(
694
+ event.get("version") for event in events
695
+ )
696
+
697
+ badges: list[tuple[str, str]] = []
698
+ if model:
699
+ badges.append(("model", str(model)))
700
+ if session_id:
701
+ sid = str(session_id)
702
+ badges.append(("session", sid[:16] + ("..." if len(sid) > 16 else "")))
703
+ if version:
704
+ badges.append(("claude code", str(version)))
705
+ return badges
706
+
707
+
708
+ def _result_cost(events: list[dict]) -> float | None:
709
+ """Total cost summed from result events, or ``None`` when no event
710
+ carries cost data — so the header can hide the badge instead of
711
+ asserting a fictional ``$0.0000``."""
712
+ total = 0.0
713
+ seen = False
657
714
  for event in events:
658
- if event.get("type") == "result":
659
- total_cost += float(event.get("total_cost_usd") or 0)
660
- session_id = str(sys_event.get("session_id") or "?")
661
- model = str(sys_event.get("model") or "?")
662
- version = str(sys_event.get("claude_code_version") or "?")
715
+ if event.get("type") == "result" and event.get("total_cost_usd") is not None:
716
+ total += float(event.get("total_cost_usd") or 0)
717
+ seen = True
718
+ return total if seen else None
719
+
720
+
721
+ def _meta_badges_html(badges: list[tuple[str, str]]) -> str:
722
+ """The header's ``.meta`` badge row; empty string when nothing is known."""
723
+ if not badges:
724
+ return ""
725
+ spans = "\n".join(
726
+ f"<span>{html.escape(label)}: {html.escape(value)}</span>"
727
+ for label, value in badges
728
+ )
729
+ return f'<div class="meta">\n{spans}\n</div>\n'
730
+
731
+
732
+ def _stream_json_page(
733
+ title: str,
734
+ events: list[dict],
735
+ turn_blocks: list[str],
736
+ *,
737
+ session_fallback: str | None = None,
738
+ ) -> str:
739
+ badges = _stream_header_badges(events, session_fallback=session_fallback)
740
+ total_cost = _result_cost(events)
741
+ if total_cost is not None:
742
+ badges.append(("total cost", f"${total_cost:.4f}"))
663
743
  return f"""<!DOCTYPE html>
664
744
  <html>
665
745
  <head>
@@ -672,13 +752,7 @@ def _stream_json_page(title: str, events: list[dict], turn_blocks: list[str]) ->
672
752
  <div class="header">
673
753
  {_WORDMARK_HTML}
674
754
  <h1>{html.escape(title)}</h1>
675
- <div class="meta">
676
- <span>model: {html.escape(model)}</span>
677
- <span>session: {html.escape(session_id[:16])}...</span>
678
- <span>claude code: {html.escape(version)}</span>
679
- <span>total cost: ${total_cost:.4f}</span>
680
- </div>
681
- </div>
755
+ {_meta_badges_html(badges)}</div>
682
756
  {_join_with_divider(turn_blocks)}
683
757
  </body>
684
758
  </html>"""
@@ -689,6 +763,7 @@ def serve(
689
763
  port: int = 8888,
690
764
  prompts: list[str] | None = None,
691
765
  confirm: bool = False,
766
+ redaction_summary: str | None = None,
692
767
  ) -> str | None:
693
768
  """Serve a trial directory or a session JSONL file as a web page.
694
769
 
@@ -698,6 +773,11 @@ def serve(
698
773
  machine-readable ``DECISION: <value>`` line to stdout. Without it the
699
774
  server runs until Ctrl+C and the return value is ``None`` — exactly the
700
775
  pre-confirm behavior (no bar, no POST endpoint).
776
+
777
+ ``redaction_summary`` is an optional caller-composed line (e.g. ``"2 API
778
+ keys, 1 bearer token"``) rendered inside the confirm bar so the reviewer
779
+ sees what upload-time redaction would mask. Presentation-only; it has no
780
+ effect without ``confirm=True``.
701
781
  """
702
782
  import threading
703
783
  from http.server import HTTPServer, SimpleHTTPRequestHandler
@@ -723,7 +803,7 @@ def serve(
723
803
  # interaction against this live server, not part of the artifact.
724
804
  (path / "trajectory.html").write_text(html_content)
725
805
  if confirm:
726
- html_content = _inject_confirm_bar(html_content)
806
+ html_content = _inject_confirm_bar(html_content, redaction_summary)
727
807
 
728
808
  print(f"Trajectory viewer: http://localhost:{port}")
729
809
  print(f"Trial: {path}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: benchflow
3
- Version: 0.7.2.dev1994
3
+ Version: 0.7.2.dev2000
4
4
  Summary: Multi-turn agent benchmarking with ACP — run any agent, any model, any provider.
5
5
  Project-URL: Homepage, https://github.com/benchflow-ai/benchflow
6
6
  Project-URL: Repository, https://github.com/benchflow-ai/benchflow
@@ -100,7 +100,7 @@ benchflow/cli/_hosted_env.py,sha256=RUwmeITDiDCNl2zimyHJwENOcFkt12uPTmWAYbPL3a4,
100
100
  benchflow/cli/_live_progress.py,sha256=tjvU8jjp6AxsJ7qXomk6Jach_nWitYFjO4nw79tjMeE,18707
101
101
  benchflow/cli/_options.py,sha256=dG2R5-jjNzvAj513Dscj1TFbH5ukM_ONxYg9vELnE7M,1237
102
102
  benchflow/cli/_shared.py,sha256=gE1sCuTUw0SzIK3H3Unpirvs9Rt28fDaVPAyS6Oyhdk,11744
103
- benchflow/cli/_traj_upload_ui.py,sha256=XjCAeNhbgy3xpaH7OKOc7Stm4WnKw4axss8TuiCbdG4,4895
103
+ benchflow/cli/_traj_upload_ui.py,sha256=2mewoumFvlKGPIz6fR_RNqyPKxmqiGRnpBlc3DBfSa0,6173
104
104
  benchflow/cli/adopt.py,sha256=HbsTrQPfxlfnq88lO3MZj1iQzwhjzor8XHoHOUDnyj0,7259
105
105
  benchflow/cli/agent.py,sha256=B4X36VIN-KUZSOKVveoJBIm2dsVx9D142CnC0ClIfLM,3547
106
106
  benchflow/cli/continue_cmd.py,sha256=I93JosVb3HJitDFMJLOhb7_jihz3UFzTvpbkf1y-kKQ,10098
@@ -108,7 +108,7 @@ benchflow/cli/environment.py,sha256=q3G8G2i0rs_wqGIWzULnnc6Nt8TlJLO12zuYlro-5-A,
108
108
  benchflow/cli/eval_artifacts.py,sha256=4CbS8aIaynCk2PUeWA4Qo31F8SH7_rxtwB7po8_O3v4,11275
109
109
  benchflow/cli/eval_lift.py,sha256=0Tyn9ZVIzAIeZ9vm0T7onBLre48gliKf0K8NrD7m1j0,3072
110
110
  benchflow/cli/hub.py,sha256=uX5O-q_9CMRb9VlW06HkA5EbKUa6xORnNNTNmMM2H_0,7029
111
- benchflow/cli/main.py,sha256=gjsxz90nwHaYq-SMpfzFG-zSbAzvxW6GZAy6JCbiWe4,48319
111
+ benchflow/cli/main.py,sha256=NQwSGLDGDttzcllFP7z3hY3KojnnelXk_49xd2aghoc,48795
112
112
  benchflow/cli/monitor.py,sha256=-0C4OzZqgUj7Lvi5obJVsK-M81OZwaZNEpis71Kqyj4,3427
113
113
  benchflow/cli/review.py,sha256=ysR6No9oFu0TgcbucNG9YmXY5FkMUHobdr6F4rHQmQw,7892
114
114
  benchflow/cli/sandbox.py,sha256=QrLASbsfzPGVosW_s5XxE3_mcXqTzbU2ZnnC9MADpXM,9849
@@ -116,7 +116,7 @@ benchflow/cli/skills.py,sha256=2Apv1iqvmstWEhY6O-PRdxKc8RYwR6f7MvbYfl2dWm0,7497
116
116
  benchflow/cli/tasks.py,sha256=b1XP1EZ79ZtK_1shF65qZyv27EcKKFZYV35M4DzxYKI,18534
117
117
  benchflow/cli/trace_import.py,sha256=NHDieFdiKQ-2Sr1-GF8Z5EDcbGsz2qq_kFE0rXacC2o,14705
118
118
  benchflow/cli/train.py,sha256=MIP_JxptitH348bWWURpcWt-TmDAw1s8d-RUU7jBaIg,23635
119
- benchflow/cli/traj.py,sha256=tX4mtAXbEwEhbwegUTHl_IBIFoxKtVlrwrlv8MPf764,24290
119
+ benchflow/cli/traj.py,sha256=uisYWo-f_R_e06sKJPMObYbvDAPJac0zzyU4byGHU7U,25515
120
120
  benchflow/continue_run/__init__.py,sha256=dWidng7kJphVUtbP1a1Ma0Lyjxj5yLgQZIApaKCJUew,1479
121
121
  benchflow/continue_run/batch.py,sha256=8BNSInyp_Ol6Fcrg4ezBJdi3cBS12nC8mf963_x4fIg,4146
122
122
  benchflow/continue_run/orchestrator.py,sha256=wYoseBNE_rZ0EjNwWDyqfave54xNRe43sCd_O4VJjyk,27676
@@ -159,9 +159,9 @@ benchflow/publish/_progress.py,sha256=TWkIH5CVtBj7TL0eqCX57_asrqoHzIYeSLn86NtueB
159
159
  benchflow/publish/azure_blob.py,sha256=CpjRdmWc2XGb2q8j8hj2G0hCEoAvHUK8-wmoUU4uZm4,5583
160
160
  benchflow/publish/broker.py,sha256=DkJPe8btYMJi5AJZmuQduUh4d9DvTloNsvtiH74VKgk,8842
161
161
  benchflow/publish/huggingface.py,sha256=V34xuQYFmL7DPi9W62LESKgzPFmTBdlJxLTZxGmpGBM,3772
162
- benchflow/publish/redact.py,sha256=SxHfCL6f7cPc5jqFp38yqvIRmKE2mfA4h1NwpiMEciA,7622
163
- benchflow/publish/traj_capture.py,sha256=hp7Ia4vFqEcqIO9we3vX11Sl7VhQtxzI6gjICsIAo4g,21968
164
- benchflow/publish/traj_report.py,sha256=3GEjrxqNkXfmJ3G_ve8GLbMTXP7UKmW0TsqJWlEefBw,24829
162
+ benchflow/publish/redact.py,sha256=spO8zTd0Q5PZ3BAs5ZYqhdNiGKUtC8NouaDO1RfDMKI,12153
163
+ benchflow/publish/traj_capture.py,sha256=Z8rBDsUf0Hyv2e6AL9RhGVLV1IxERk97hTEa5ghM-mw,22797
164
+ benchflow/publish/traj_report.py,sha256=2iYeipPrfQcTlAeNhDhWk-vbBTdA5RdXCjcWdEi_VWo,25559
165
165
  benchflow/review/__init__.py,sha256=vl7jnLnHGG9aKBux2F9Ni5rf_EPsC-tvauHL-QnyRa4,2080
166
166
  benchflow/review/config.py,sha256=WGxH4dRd-qCQVf8hDf4AwXKKGVPx2zkPpna6dgwxFMk,9002
167
167
  benchflow/review/default-rubric.json,sha256=Ed5cTrt_q3e7TGERWbjJgNWrrhkTsB_ToP0UMn1vobg,1843
@@ -290,10 +290,10 @@ benchflow/trajectories/results.py,sha256=VAHktYYwMN8ikjSr2OlAgP8IsDpDJp5AJiZGrYi
290
290
  benchflow/trajectories/sessions.py,sha256=u_6x1WyT8YtKUVOb6b3myTCHhqn58a8NHCLAvQqKP_Y,4437
291
291
  benchflow/trajectories/tree.py,sha256=c0jyoP9OurDQq-zRtPc66edzWUvLGwQQz0jgtyqSw2U,5576
292
292
  benchflow/trajectories/trl_sft_tokenization.py,sha256=-Ef0bP9cuzfzOz5nIv2mEF3taQIEw68h6SM1YO-Mwn4,8643
293
- benchflow/trajectories/types.py,sha256=aSJclec3dOCUlfFNDL8GfzvkAcBo5r-dvlHE0MnGdMo,27554
294
- benchflow/trajectories/viewer.py,sha256=4V7HMjJtUt8_HE1dStZwzF9W_3yefHNas7R3Y1iZmvY,34760
295
- benchflow-0.7.2.dev1994.dist-info/METADATA,sha256=BZC_LqHIgXKHtV4fMAqIDeaodQRbu2iDK2awvDuJ3fI,15565
296
- benchflow-0.7.2.dev1994.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
297
- benchflow-0.7.2.dev1994.dist-info/entry_points.txt,sha256=qPMBVtH4qz3P4nMALEAdTbvpxvsVch9RhqshZKQn1dE,84
298
- benchflow-0.7.2.dev1994.dist-info/licenses/LICENSE,sha256=ohnbHkuBylrTtOvaZJk9Hz7dRwmaEEGAb5ZcBV4Xuhw,10779
299
- benchflow-0.7.2.dev1994.dist-info/RECORD,,
293
+ benchflow/trajectories/types.py,sha256=EG_TDTh0JveXk5WJX4YMcEDBjfe3526lC7JjDvQmiw8,30067
294
+ benchflow/trajectories/viewer.py,sha256=L-tMiR15qdGBaTlVNgY4dOS1pIrf4PB24MvNic6lj4s,37715
295
+ benchflow-0.7.2.dev2000.dist-info/METADATA,sha256=IYq47CWBBGAdRObAxld55c_Nbt_mma-kzn0JFrADhbw,15565
296
+ benchflow-0.7.2.dev2000.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
297
+ benchflow-0.7.2.dev2000.dist-info/entry_points.txt,sha256=qPMBVtH4qz3P4nMALEAdTbvpxvsVch9RhqshZKQn1dE,84
298
+ benchflow-0.7.2.dev2000.dist-info/licenses/LICENSE,sha256=ohnbHkuBylrTtOvaZJk9Hz7dRwmaEEGAb5ZcBV4Xuhw,10779
299
+ benchflow-0.7.2.dev2000.dist-info/RECORD,,