Skip to content

Commit 02ccbe3

Browse files
committed
refactor(harbor): simplify verifier evidence ingestion
1 parent 71d4294 commit 02ccbe3

3 files changed

Lines changed: 93 additions & 142 deletions

File tree

‎examples/harbor/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ uv run harbor run \
3939

4040
The agent solves the task in `task/`, and Harbor's verifier emits a normalized `reward` plus an `answer_length` metric. The plugin creates `jobs/braintrust-harbor-example/braintrust-sync.json` after synchronization.
4141

42-
With the default `attachments=verifier-details` mode, the verification span and each score also include Harbor's captured `test-stdout.txt`, optional `test-stderr.txt`, and conventional `ctrf.json` output when present. The size-bounded summary and complete credential-scrubbed `verifier-output.json` attachment appear together in the span output. Common authorization headers, credential assignments, and provider token formats are scrubbed automatically; use `redact_patterns` for application-specific secrets.
42+
With the default `attachments=verifier-details` mode, the verification span and each score also include Harbor's captured `test-stdout.txt`, optional `test-stderr.txt`, and conventional `ctrf.json` output when present. The size-bounded summary and complete redacted `verifier-output.json` attachment appear together in the span output. Structured CTRF fields with sensitive key names use the plugin's standard redaction, and configured `redact_patterns` apply to both structured CTRF strings and raw verifier text.
4343

4444
By default, the plugin uses `Harbor` as the Braintrust project name. Override it with `--plugin-kwarg project_name=example-harbor` or through `.env`:
4545

‎py/src/braintrust/integrations/harbor/plugin.py‎

Lines changed: 83 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import json
1010
import logging
1111
import os
12-
import re
1312
import stat
1413
from dataclasses import dataclass, field, fields, replace
1514
from datetime import datetime
@@ -60,14 +59,6 @@
6059
_PLUGIN_VERSION = "1"
6160
_MANIFEST_VERSION = 1
6261
_DEFAULT_PROJECT = "Harbor"
63-
_VERIFIER_SECRET_ASSIGNMENT = re.compile(
64-
r"(?i)(?P<prefix>[\"']?\b(?:[A-Za-z0-9_-]*api[-_]?key[A-Za-z0-9_-]*|token|access[-_]?token|"
65-
r"auth[-_]?token|refresh[-_]?token|id[-_]?token|[A-Za-z0-9_-]*(?:secret|password|passwd|credential|"
66-
r"authorization|cookie)[A-Za-z0-9_-]*)[\"']?\s*[:=]\s*)"
67-
r"(?:[\"'][^\"'\r\n]*[\"']|(?:Bearer|Basic)\s+[^\s,;]+|[^\s,;]+)"
68-
)
69-
_VERIFIER_BEARER_SECRET = re.compile(r"(?i)(\bBearer\s+)[A-Za-z0-9._~+/=-]+")
70-
_VERIFIER_TOKEN_SECRET = re.compile(r"\b(?:sk-|gh[pousr]_|github_pat_|xox[baprs]-)[A-Za-z0-9_-]{8,}\b", re.IGNORECASE)
7162

7263

7364
@dataclass
@@ -180,58 +171,18 @@ def _read_bounded_file(path: Path, max_bytes: int) -> tuple[bytes | None, str |
180171
if before.st_size > max_bytes:
181172
return None, "attachment size limit"
182173
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
183-
descriptor = os.open(path, flags)
174+
with os.fdopen(os.open(path, flags), "rb") as file_obj:
175+
opened = os.fstat(file_obj.fileno())
176+
if not stat.S_ISREG(opened.st_mode) or (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
177+
return None, "unsafe file type"
178+
data = file_obj.read(max_bytes + 1)
184179
except FileNotFoundError:
185180
return None, None
186181
except OSError as exc:
187182
return None, str(exc)
188-
try:
189-
opened = os.fstat(descriptor)
190-
if not stat.S_ISREG(opened.st_mode) or (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
191-
return None, "unsafe file type"
192-
if opened.st_size > max_bytes:
193-
return None, "attachment size limit"
194-
chunks = []
195-
remaining = max_bytes + 1
196-
while remaining:
197-
chunk = os.read(descriptor, remaining)
198-
if not chunk:
199-
break
200-
chunks.append(chunk)
201-
remaining -= len(chunk)
202-
data = b"".join(chunks)
203-
if len(data) > max_bytes:
204-
return None, "attachment size limit"
205-
return data, None
206-
except OSError as exc:
207-
return None, str(exc)
208-
finally:
209-
os.close(descriptor)
210-
211-
212-
def _redact_verifier_value(value: Any) -> tuple[Any, bool]:
213-
"""Scrub common credential forms from raw logs and nested CTRF strings."""
214-
if isinstance(value, str):
215-
redacted = _VERIFIER_SECRET_ASSIGNMENT.sub(r"\g<prefix>[REDACTED]", value)
216-
redacted = _VERIFIER_BEARER_SECRET.sub(r"\1[REDACTED]", redacted)
217-
redacted = _VERIFIER_TOKEN_SECRET.sub("[REDACTED]", redacted)
218-
return redacted, redacted != value
219-
if isinstance(value, dict):
220-
result = {}
221-
changed = False
222-
for key, child in value.items():
223-
result[key], child_changed = _redact_verifier_value(child)
224-
changed = changed or child_changed
225-
return result, changed
226-
if isinstance(value, list):
227-
result = []
228-
changed = False
229-
for child in value:
230-
redacted_child, child_changed = _redact_verifier_value(child)
231-
result.append(redacted_child)
232-
changed = changed or child_changed
233-
return result, changed
234-
return value, False
183+
if opened.st_size > max_bytes or len(data) > max_bytes:
184+
return None, "attachment size limit"
185+
return data, None
235186

236187

237188
def _read_json_summary(entries: list[tuple[str | None, Path]], max_bytes: int) -> tuple[Any, list[str]]:
@@ -340,52 +291,58 @@ def _attachment(
340291
)
341292

342293

294+
_VERIFIER_OUTPUT_FILES = (
295+
("stdout", "test-stdout.txt", False),
296+
("stderr", "test-stderr.txt", False),
297+
("ctrf", "ctrf.json", True),
298+
)
299+
300+
301+
def _decode_verifier_output(data: bytes, parse_json: bool) -> tuple[Any, list[str]]:
302+
if not parse_json:
303+
return data.decode("utf-8", errors="replace"), []
304+
try:
305+
return json.loads(data), []
306+
except (UnicodeDecodeError, json.JSONDecodeError):
307+
return data.decode("utf-8", errors="replace"), ["is not valid JSON"]
308+
309+
310+
def _read_verifier_output(
311+
path: Path, parse_json: bool, config: PluginConfig, max_bytes: int
312+
) -> tuple[Any | None, int, list[str]]:
313+
data, read_warning = _read_bounded_file(path, max_bytes)
314+
if data is None:
315+
return None, 0, [] if read_warning is None else [f"omitted: {read_warning}"]
316+
if not data:
317+
return None, 0, []
318+
value, warnings = _decode_verifier_output(data, parse_json)
319+
normalized = normalize_json(
320+
value,
321+
max_bytes=config.max_attachment_bytes,
322+
redact_patterns=config.redact_patterns,
323+
max_depth=20,
324+
redact_absolute_paths=False,
325+
)
326+
return normalized.value, len(data), [*warnings, *normalized.warnings]
327+
328+
343329
def _verifier_output_attachment(result: Any, config: PluginConfig) -> tuple[Attachment | None, Any, list[str]]:
344330
if config.attachments == "none":
345331
return None, None, []
346-
347-
output_files = (
348-
("stdout", "test-stdout.txt", False),
349-
("stderr", "test-stderr.txt", False),
350-
("ctrf", "ctrf.json", True),
351-
)
352332
outputs: list[tuple[str | None, dict[str, Any]]] = []
353333
warnings: list[str] = []
354334
total = 0
355335
for step_name, verifier_dir in verifier_output_paths(result):
356336
step_output: dict[str, Any] = {}
357-
for key, filename, parse_json in output_files:
337+
for key, filename, parse_json in _VERIFIER_OUTPUT_FILES:
358338
path = verifier_dir / filename
359339
label = _step_label(step_name, path)
360340
limit = min(config.max_attachment_bytes, config.max_total_attachment_bytes - total)
361-
data, read_warning = _read_bounded_file(path, limit)
362-
if data is None:
363-
if read_warning is not None:
364-
warnings.append(f"{label} omitted: {read_warning}")
365-
continue
366-
if not data:
367-
continue
368-
if parse_json:
369-
try:
370-
value: Any = json.loads(data)
371-
except (UnicodeDecodeError, json.JSONDecodeError):
372-
warnings.append(f"{label} is not valid JSON")
373-
value = data.decode("utf-8", errors="replace")
374-
else:
375-
value = data.decode("utf-8", errors="replace")
376-
value, redacted = _redact_verifier_value(value)
377-
if redacted:
378-
warnings.append(f"{label}: redacted credential-like text")
379-
normalized = normalize_json(
380-
value,
381-
max_bytes=config.max_attachment_bytes,
382-
redact_patterns=config.redact_patterns,
383-
max_depth=20,
384-
redact_absolute_paths=False,
385-
)
386-
warnings.extend(f"{label}: {warning}" for warning in normalized.warnings)
387-
step_output[key] = normalized.value
388-
total += len(data)
341+
value, size, file_warnings = _read_verifier_output(path, parse_json, config, limit)
342+
warnings.extend(f"{label} {warning}" for warning in file_warnings)
343+
if value is not None:
344+
step_output[key] = value
345+
total += size
389346
if step_output:
390347
outputs.append((step_name, step_output))
391348

@@ -403,6 +360,33 @@ def _verifier_output_attachment(result: Any, config: PluginConfig) -> tuple[Atta
403360
)
404361

405362

363+
def _remaining_attachment_config(config: PluginConfig, used_bytes: int) -> PluginConfig:
364+
return replace(config, max_total_attachment_bytes=max(0, config.max_total_attachment_bytes - used_bytes))
365+
366+
367+
def _attachments_size(attachments: dict[str, Attachment]) -> int:
368+
return sum(len(attachment.data) for attachment in attachments.values())
369+
370+
371+
def _verifier_evidence(result: Any, config: PluginConfig) -> tuple[dict[str, Any], list[str], int]:
372+
attachment, summary, warnings = _verifier_output_attachment(result, config)
373+
output: dict[str, Any] = {}
374+
if summary is not None:
375+
output["verifier_output_summary"] = normalize_json(
376+
summary,
377+
max_bytes=config.max_content_bytes,
378+
redact_patterns=config.redact_patterns,
379+
redact_absolute_paths=False,
380+
).value
381+
if attachment is not None:
382+
output["verifier_output"] = attachment
383+
return output, warnings, 0 if attachment is None else len(attachment.data)
384+
385+
386+
def _output_event(output: dict[str, Any]) -> dict[str, Any]:
387+
return {} if not output else {"output": output}
388+
389+
406390
class HarborPlugin:
407391
"""Harbor plugin that reconciles final trials into Braintrust experiments."""
408392

@@ -846,7 +830,7 @@ def _sync_final_result(self, result: Any) -> None:
846830
execution_input["extra_instructions"] = extra_instructions
847831
selected_artifacts, artifact_attachment_warnings = _artifact_attachments(result, self.config)
848832
metadata["harbor"]["warnings"].extend(artifact_attachment_warnings)
849-
attachment_bytes = sum(len(attachment.data) for attachment in selected_artifacts.values())
833+
attachment_bytes = _attachments_size(selected_artifacts)
850834
agent_span = task.start_span(
851835
name="agent_execution",
852836
type="task",
@@ -879,30 +863,10 @@ def _sync_final_result(self, result: Any) -> None:
879863
if selected_artifacts:
880864
agent_span.log(output={"artifacts": selected_artifacts})
881865
agent_span.end(end_time=agent_end)
882-
verifier_config = replace(
883-
self.config,
884-
max_total_attachment_bytes=max(0, self.config.max_total_attachment_bytes - attachment_bytes),
885-
)
886-
verifier_attachment, verifier_summary, verifier_warnings = _verifier_output_attachment(result, verifier_config)
866+
verifier_config = _remaining_attachment_config(self.config, attachment_bytes)
867+
verifier_output, verifier_warnings, verifier_attachment_bytes = _verifier_evidence(result, verifier_config)
887868
metadata["harbor"]["warnings"].extend(verifier_warnings)
888-
if verifier_attachment is not None:
889-
attachment_bytes += len(verifier_attachment.data)
890-
bounded_verifier_summary = (
891-
None
892-
if verifier_summary is None
893-
else normalize_json(
894-
verifier_summary,
895-
max_bytes=self.config.max_content_bytes,
896-
redact_patterns=self.config.redact_patterns,
897-
redact_absolute_paths=False,
898-
).value
899-
)
900-
verification_output: dict[str, Any] = {}
901-
if bounded_verifier_summary is not None:
902-
verification_output["verifier_output_summary"] = bounded_verifier_summary
903-
if verifier_attachment is not None:
904-
verification_output["verifier_output"] = verifier_attachment
905-
verification_event = {} if not verification_output else {"output": verification_output}
869+
attachment_bytes += verifier_attachment_bytes
906870
self._start_phase(
907871
task,
908872
result,
@@ -911,7 +875,7 @@ def _sync_final_result(self, result: Any) -> None:
911875
trial_id,
912876
root_start,
913877
root_end,
914-
**verification_event,
878+
**_output_event(verifier_output),
915879
)
916880

917881
for step in getattr(result, "step_results", None) or []:
@@ -979,10 +943,7 @@ def _sync_final_result(self, result: Any) -> None:
979943
task.log(metadata={"harbor": {"warnings": trajectory_warnings}})
980944
task.end(end_time=root_end)
981945

982-
details_config = replace(
983-
self.config,
984-
max_total_attachment_bytes=max(0, self.config.max_total_attachment_bytes - attachment_bytes),
985-
)
946+
details_config = _remaining_attachment_config(self.config, attachment_bytes)
986947
details_attachment, details_summary, detail_warnings = _attachment(
987948
reward_details_paths(result), details_config
988949
)
@@ -1014,10 +975,7 @@ def _sync_final_result(self, result: Any) -> None:
1014975
scorer_output["reward_details_summary"] = bounded_details
1015976
if details_attachment is not None:
1016977
scorer_output["reward_details"] = details_attachment
1017-
if bounded_verifier_summary is not None:
1018-
scorer_output["verifier_output_summary"] = bounded_verifier_summary
1019-
if verifier_attachment is not None:
1020-
scorer_output["verifier_output"] = verifier_attachment
978+
scorer_output.update(verifier_output)
1021979
scorer.log(output=scorer_output, scores={score.name: score.value})
1022980
scorer.end(end_time=root_end)
1023981

‎py/src/braintrust/integrations/harbor/test_harbor.py‎

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,7 @@ def test_verifier_output_attachment_collects_standard_harbor_evidence(tmp_path):
500500
)
501501
)
502502

503-
config = PluginConfig.from_options()
503+
config = PluginConfig.from_options(redact_patterns=(r"secret-value|Bearer verifier-secret",))
504504
attachment, summary, warnings = _verifier_output_attachment(result, config)
505505

506506
assert summary == {
@@ -523,34 +523,27 @@ def test_verifier_output_attachment_collects_standard_harbor_evidence(tmp_path):
523523
assert attachment is not None
524524
assert attachment.reference["filename"] == "verifier-output.json"
525525
assert json.loads(attachment.data) == summary
526-
assert any("redacted credential-like text" in warning for warning in warnings)
526+
assert warnings == []
527527

528528

529-
def test_verifier_output_attachment_handles_invalid_utf8_and_scrubs_common_credentials(tmp_path):
529+
def test_verifier_output_attachment_handles_invalid_utf8_and_configured_redaction(tmp_path):
530530
result = _trial_result(tmp_path, "trial-1", "task-a")
531531
verifier_dir = tmp_path / "trial-1" / "verifier"
532532
verifier_dir.mkdir(parents=True)
533-
(verifier_dir / "test-stdout.txt").write_text(
534-
"Authorization: Bearer header-secret\n"
535-
"OPENAI_API_KEY=sk-example123456\n"
536-
"github=ghp_abcdefghijklmnopqrstuvwxyz\n"
537-
"fine_grained=github_pat_abcdefghijklmnopqrstuvwxyz\n"
538-
)
533+
(verifier_dir / "test-stdout.txt").write_text("Authorization: Bearer header-secret\n")
539534
(verifier_dir / "ctrf.json").write_bytes(b"\xff token=opaque-secret\n")
540535

541-
attachment, summary, warnings = _verifier_output_attachment(result, PluginConfig.from_options())
536+
attachment, summary, warnings = _verifier_output_attachment(
537+
result,
538+
PluginConfig.from_options(redact_patterns=(r"(?:header|opaque)-secret",)),
539+
)
542540

543541
assert attachment is not None
544542
assert summary == {
545-
"stdout": (
546-
"Authorization: [REDACTED]\nOPENAI_API_KEY=[REDACTED]\ngithub=[REDACTED]\nfine_grained=[REDACTED]\n"
547-
),
543+
"stdout": "Authorization: Bearer [REDACTED]\n",
548544
"ctrf": "\ufffd token=[REDACTED]\n",
549545
}
550-
assert "ghp_" not in summary["stdout"]
551-
assert "github_pat_" not in summary["stdout"]
552546
assert any("ctrf.json is not valid JSON" in warning for warning in warnings)
553-
assert sum("redacted credential-like text" in warning for warning in warnings) == 2
554547

555548

556549
def test_verifier_output_attachment_rejects_oversized_file_before_reading_it(tmp_path, monkeypatch):

0 commit comments

Comments
 (0)