4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
370 lines
14 KiB
Python
370 lines
14 KiB
Python
"""Per-run provenance capture — write `provenance.json` next to `report.json`.
|
|
|
|
A reviewer auditing a published benchmark report has to answer three
|
|
questions:
|
|
|
|
1. *What code ran?* — git SHA, branch, dirty-state, list of changed files
|
|
2. *Can I reproduce it?* — full config + pre-registration content inline,
|
|
pinned model versions, Python + key package versions
|
|
3. *Did any secrets leak into the artifact?* — env-var whitelist, never
|
|
dump arbitrary env
|
|
|
|
This module is the single source of truth for those three answers. It runs
|
|
once at the start of every benchmark run, before any LLM call, and writes
|
|
its output to ``output_dir/provenance.json``. The runner cannot proceed
|
|
without succeeding here.
|
|
|
|
Security discipline:
|
|
- API-key-shaped env vars (``*_API_KEY``, ``*_TOKEN``, ``*_SECRET``,
|
|
``*_PASSWORD``, ``*_KEY``) are NEVER captured, even if explicitly named
|
|
in the whitelist
|
|
- Hostname / username are NOT captured by default (would leak who ran it)
|
|
- Uncommitted-changes capture is filename-only — no diff content
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from collections.abc import Iterable
|
|
from importlib import metadata as importlib_metadata
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import platform
|
|
from tests.benchmarks._framework.adapters import BenchmarkAdapter
|
|
from tests.benchmarks._framework.config import BenchmarkConfig
|
|
from tests.benchmarks._framework.cost import lookup_pricing
|
|
from tests.benchmarks._framework.llm_dispatch import LLMDispatcher
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Constants #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
# Bumped only when the provenance schema changes in a backwards-incompatible
|
|
# way. Reviewers diffing two runs use this to know which fields they can
|
|
# compare 1:1.
|
|
PROVENANCE_SCHEMA_VERSION = 1
|
|
|
|
# Env vars whose presence + value is safe to record. Anything else is dropped.
|
|
# The values themselves are still passed through the secret-pattern filter so
|
|
# a malicious entry on the allowlist can't smuggle a key.
|
|
_ENV_ALLOWLIST: frozenset[str] = frozenset(
|
|
{
|
|
"OPENSRE_BENCH_WORKERS",
|
|
"OPENSRE_BENCH_COST_BUDGET_USD",
|
|
"BENCH_MIN_TOOL_CALLS",
|
|
"PYTHONPATH",
|
|
"LANG",
|
|
"LC_ALL",
|
|
"CI",
|
|
"GITHUB_ACTIONS",
|
|
"GITHUB_RUN_ID",
|
|
"GITHUB_SHA",
|
|
}
|
|
)
|
|
|
|
# Substring patterns that mark an env var as secret-bearing. Belt-and-braces:
|
|
# any var matching these is dropped even if it slipped onto the allowlist.
|
|
_SECRET_NAME_PATTERNS: tuple[str, ...] = (
|
|
"_API_KEY",
|
|
"_TOKEN",
|
|
"_SECRET",
|
|
"_PASSWORD",
|
|
"_KEY",
|
|
"PASSPHRASE",
|
|
"PRIVATE",
|
|
"CREDENTIALS",
|
|
"AUTH",
|
|
)
|
|
|
|
# Key packages to record versions for. Failures (PackageNotFoundError) are
|
|
# captured as None rather than raising.
|
|
_KEY_PACKAGES: tuple[str, ...] = (
|
|
"anthropic",
|
|
"openai",
|
|
"boto3",
|
|
"pydantic",
|
|
"pyyaml",
|
|
"huggingface-hub",
|
|
"datasets",
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Public API #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def capture_provenance(
|
|
*,
|
|
config: BenchmarkConfig,
|
|
adapter: BenchmarkAdapter,
|
|
run_id: str,
|
|
started_at: str,
|
|
config_path: Path | None = None,
|
|
pre_registration_path: Path | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Snapshot everything needed to reproduce / audit this run.
|
|
|
|
Args:
|
|
config: the BenchmarkConfig the runner loaded
|
|
adapter: the BenchmarkAdapter the runner will call
|
|
run_id: framework-generated run id (e.g. ``2026-05-18T18-30-00Z_cloudopsbench``)
|
|
started_at: ISO-8601 UTC timestamp the runner stamped
|
|
config_path: optional path to the YAML the config was loaded from
|
|
(passed when running via the CLI; absent when constructed inline)
|
|
pre_registration_path: optional path to the pre-registration YAML
|
|
|
|
Returns:
|
|
Plain dict, JSON-serializable, suitable for ``json.dump`` to
|
|
``provenance.json`` in the run directory.
|
|
"""
|
|
provenance: dict[str, Any] = {
|
|
"schema_version": PROVENANCE_SCHEMA_VERSION,
|
|
"run_id": run_id,
|
|
"started_at": started_at,
|
|
"code": _git_state(),
|
|
"config": _config_section(config, config_path),
|
|
"pre_registration": _pre_registration_section(
|
|
config.pre_registration_path or pre_registration_path
|
|
),
|
|
"models": _models_section(config),
|
|
"environment": _environment_section(),
|
|
"dataset": _dataset_section(adapter),
|
|
"run_inputs": _run_inputs_section(config),
|
|
}
|
|
# Strategy hook — the framework's job is to assemble the standard
|
|
# sections; adapters extend with their own keys (e.g. CloudOpsBench
|
|
# adds ``min_tool_calls`` to ``run_inputs``). See BenchmarkAdapter.
|
|
return adapter.extend_provenance(provenance)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Code (git) section #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _git_state() -> dict[str, Any]:
|
|
"""Best-effort git provenance. Falls back gracefully if no git available."""
|
|
try:
|
|
sha_full = _run_git("rev-parse", "HEAD") or "(unknown)"
|
|
sha_short = _run_git("rev-parse", "--short", "HEAD") or "(unknown)"
|
|
branch = _run_git("rev-parse", "--abbrev-ref", "HEAD") or "(unknown)"
|
|
# NB: fetch porcelain UNSTRIPPED. ``git status --porcelain`` emits
|
|
# ``XY PATH`` where column 0 (X = index state) is a SIGNIFICANT space for
|
|
# unstaged-only changes (e.g. " M tools/investigation/stages/gather_evidence/agent.py"). The
|
|
# default strip in ``_run_git`` would eat that leading space on the first
|
|
# line, shifting it left so ``line[3:]`` slices into the path and drops
|
|
# its first character (" M app…" → "pp/agent/…"). Keep the raw spacing so
|
|
# the fixed-width [3:] slice lands exactly on the path.
|
|
status_porcelain = _run_git("status", "--porcelain", strip=False) or ""
|
|
changed_files = [
|
|
line[3:].strip()
|
|
for line in status_porcelain.splitlines()
|
|
if len(line) > 3 and line[3:].strip()
|
|
]
|
|
return {
|
|
"opensre_sha": sha_full,
|
|
"opensre_short_sha": sha_short,
|
|
"opensre_branch": branch,
|
|
"opensre_dirty": bool(changed_files),
|
|
"opensre_changed_files": changed_files,
|
|
}
|
|
except (FileNotFoundError, OSError):
|
|
return {
|
|
"opensre_sha": "(no-git)",
|
|
"opensre_short_sha": "(no-git)",
|
|
"opensre_branch": "(no-git)",
|
|
"opensre_dirty": False,
|
|
"opensre_changed_files": [],
|
|
}
|
|
|
|
|
|
def _run_git(*args: str, strip: bool = True) -> str | None:
|
|
"""Run a git command; return stdout (stripped by default) or None on failure.
|
|
|
|
Pass ``strip=False`` for commands whose leading/trailing whitespace is
|
|
significant — notably ``status --porcelain``, where column 0 is a meaningful
|
|
space for unstaged changes.
|
|
"""
|
|
result = subprocess.run(
|
|
["git", *args],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
cwd=Path(__file__).parent,
|
|
)
|
|
if result.returncode != 0:
|
|
return None
|
|
return result.stdout.strip() if strip else result.stdout
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Config / pre-registration sections — inline content so runs are self-contained
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _config_section(config: BenchmarkConfig, config_path: Path | None) -> dict[str, Any]:
|
|
section: dict[str, Any] = {
|
|
"path": str(config_path) if config_path else None,
|
|
"sha256": None,
|
|
"content": None,
|
|
}
|
|
if config_path is not None and config_path.exists():
|
|
content = config_path.read_text(encoding="utf-8")
|
|
section["sha256"] = _sha256(content)
|
|
section["content"] = content
|
|
return section
|
|
|
|
|
|
def _pre_registration_section(pre_reg_path: Path | None) -> dict[str, Any]:
|
|
section: dict[str, Any] = {
|
|
"path": str(pre_reg_path) if pre_reg_path else None,
|
|
"sha256": None,
|
|
"content": None,
|
|
}
|
|
if pre_reg_path is not None and pre_reg_path.exists():
|
|
content = pre_reg_path.read_text(encoding="utf-8")
|
|
section["sha256"] = _sha256(content)
|
|
section["content"] = content
|
|
return section
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Models section — per-LLM spec + pricing snapshot #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _models_section(config: BenchmarkConfig) -> dict[str, dict[str, Any]]:
|
|
out: dict[str, dict[str, Any]] = {}
|
|
for llm in config.llms:
|
|
configured_version = config.model_versions.get(llm, "")
|
|
entry: dict[str, Any] = {
|
|
"configured_version": configured_version,
|
|
}
|
|
try:
|
|
spec = LLMDispatcher.spec(llm)
|
|
entry["provider"] = str(spec.provider)
|
|
entry["spec_reasoning_model"] = spec.reasoning_model
|
|
entry["spec_classification_model"] = spec.classification_model
|
|
entry["spec_toolcall_model"] = spec.toolcall_model
|
|
except Exception as exc:
|
|
entry["spec_error"] = f"{type(exc).__name__}: {exc}"
|
|
|
|
try:
|
|
pricing = lookup_pricing(configured_version)
|
|
entry["pricing_snapshot"] = {
|
|
"input_usd_per_mtok": pricing.input_usd_per_mtok,
|
|
"output_usd_per_mtok": pricing.output_usd_per_mtok,
|
|
}
|
|
except Exception as exc:
|
|
entry["pricing_error"] = f"{type(exc).__name__}: {exc}"
|
|
|
|
out[llm] = entry
|
|
return out
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Environment section — Python + key packages + safe env-var snapshot #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _environment_section() -> dict[str, Any]:
|
|
return {
|
|
"python_version": sys.version.split()[0],
|
|
"python_implementation": platform.python_implementation(),
|
|
"platform": platform.platform(),
|
|
"machine": platform.machine(),
|
|
"key_packages": _package_versions(_KEY_PACKAGES),
|
|
"env": _safe_env_snapshot(),
|
|
}
|
|
|
|
|
|
def _package_versions(packages: Iterable[str]) -> dict[str, str | None]:
|
|
out: dict[str, str | None] = {}
|
|
for name in packages:
|
|
try:
|
|
out[name] = importlib_metadata.version(name)
|
|
except importlib_metadata.PackageNotFoundError:
|
|
out[name] = None
|
|
return out
|
|
|
|
|
|
def _safe_env_snapshot() -> dict[str, str]:
|
|
"""Whitelist + secret-pattern filter. Drops anything risky."""
|
|
out: dict[str, str] = {}
|
|
for name in _ENV_ALLOWLIST:
|
|
if _looks_like_secret(name):
|
|
# Belt-and-braces — never let a secret-shaped allowlist entry through.
|
|
continue
|
|
value = os.environ.get(name)
|
|
if value is None:
|
|
continue
|
|
out[name] = value
|
|
return out
|
|
|
|
|
|
def _looks_like_secret(name: str) -> bool:
|
|
upper = name.upper()
|
|
return any(pattern in upper for pattern in _SECRET_NAME_PATTERNS)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Dataset section — best-effort from adapter attrs #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _dataset_section(adapter: BenchmarkAdapter) -> dict[str, Any]:
|
|
"""Adapters can expose dataset provenance via well-known attributes; we
|
|
grab what's available and leave the rest as None.
|
|
"""
|
|
return {
|
|
"adapter_name": adapter.name,
|
|
"adapter_version": adapter.version,
|
|
"hf_dataset": getattr(adapter, "hf_dataset", None),
|
|
"hf_revision": getattr(adapter, "hf_revision", None),
|
|
"local_path": _stringify_optional_path(getattr(adapter, "benchmark_dir", None)),
|
|
"data_contamination_checked": bool(getattr(adapter, "data_contamination_checked", False)),
|
|
}
|
|
|
|
|
|
def _stringify_optional_path(value: Any) -> str | None:
|
|
if value is None:
|
|
return None
|
|
return str(value)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Run-inputs section — echo from config for quick scanning #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _run_inputs_section(config: BenchmarkConfig) -> dict[str, Any]:
|
|
# Adapter-agnostic. Knobs that belong to a specific adapter (e.g.
|
|
# CloudOpsBench's ``min_tool_calls``) are injected via the adapter's
|
|
# ``extend_provenance`` hook, NOT by the framework reaching into the
|
|
# adapter's internals. See BenchmarkAdapter.extend_provenance.
|
|
return {
|
|
"modes": list(config.modes),
|
|
"llms": list(config.llms),
|
|
"model_versions": dict(config.model_versions),
|
|
"runs_per_case": config.runs_per_case,
|
|
"workers": config.workers,
|
|
"cost_budget_usd": config.cost_budget_usd,
|
|
"seed": config.seed,
|
|
"filters": config.filters.model_dump(),
|
|
"report_formats": list(config.report_formats),
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Helpers #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _sha256(content: str) -> str:
|
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|