eval-builder 0.1.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.
- eval_builder/__init__.py +3 -0
- eval_builder/cli.py +354 -0
- eval_builder/data/SKILL.md +78 -0
- eval_builder/draft.py +271 -0
- eval_builder/export.py +284 -0
- eval_builder/ingest/__init__.py +181 -0
- eval_builder/ingest/formats.py +645 -0
- eval_builder/io.py +82 -0
- eval_builder/judge/__init__.py +1 -0
- eval_builder/judge/check.py +420 -0
- eval_builder/judge/plan.py +151 -0
- eval_builder/judge/run.py +149 -0
- eval_builder/judge/stats.py +76 -0
- eval_builder/mcp_server.py +175 -0
- eval_builder/redact.py +101 -0
- eval_builder/report.py +263 -0
- eval_builder/schema.py +242 -0
- eval_builder/select.py +412 -0
- eval_builder/setup_agents.py +190 -0
- eval_builder/status.py +32 -0
- eval_builder/workspace.py +67 -0
- eval_builder-0.1.0.dist-info/METADATA +154 -0
- eval_builder-0.1.0.dist-info/RECORD +26 -0
- eval_builder-0.1.0.dist-info/WHEEL +4 -0
- eval_builder-0.1.0.dist-info/entry_points.txt +2 -0
- eval_builder-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Optional judge runner. OFF by default.
|
|
2
|
+
|
|
3
|
+
eval-builder never calls a model provider and ships no API keys. This module only
|
|
4
|
+
starts a command YOU name (for example a script that calls your local model or your
|
|
5
|
+
provider), sends it one JSON request per line on stdin, and reads one JSON response
|
|
6
|
+
per line from stdout:
|
|
7
|
+
|
|
8
|
+
request (stdin): the row from judge_requests.jsonl (includes "prompt" when the
|
|
9
|
+
rubric judge has a prompt template)
|
|
10
|
+
response (stdout): {"verdict": "<label>", "raw": "<optional raw text>"}
|
|
11
|
+
|
|
12
|
+
It refuses to run unless enabled with --enable-judge-plugin or the environment
|
|
13
|
+
variable EVAL_BUILDER_ENABLE_JUDGE_PLUGIN=1.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import queue
|
|
21
|
+
import shlex
|
|
22
|
+
import subprocess
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from ..io import read_json, read_jsonl, write_json
|
|
30
|
+
from ..workspace import Workspace
|
|
31
|
+
|
|
32
|
+
ENV_FLAG = "EVAL_BUILDER_ENABLE_JUDGE_PLUGIN"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PluginDisabled(PermissionError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def plugin_enabled(flag: bool) -> bool:
|
|
40
|
+
return flag or os.environ.get(ENV_FLAG) == "1"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _LineProcess:
|
|
44
|
+
def __init__(self, argv: list[str]) -> None:
|
|
45
|
+
self.proc = subprocess.Popen(
|
|
46
|
+
argv,
|
|
47
|
+
stdin=subprocess.PIPE,
|
|
48
|
+
stdout=subprocess.PIPE,
|
|
49
|
+
stderr=subprocess.PIPE,
|
|
50
|
+
text=True,
|
|
51
|
+
bufsize=1,
|
|
52
|
+
)
|
|
53
|
+
self.lines: queue.Queue[str | None] = queue.Queue()
|
|
54
|
+
threading.Thread(target=self._pump, daemon=True).start()
|
|
55
|
+
|
|
56
|
+
def _pump(self) -> None:
|
|
57
|
+
assert self.proc.stdout is not None
|
|
58
|
+
for line in self.proc.stdout:
|
|
59
|
+
self.lines.put(line)
|
|
60
|
+
self.lines.put(None)
|
|
61
|
+
|
|
62
|
+
def ask(self, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
63
|
+
assert self.proc.stdin is not None
|
|
64
|
+
self.proc.stdin.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
65
|
+
self.proc.stdin.flush()
|
|
66
|
+
try:
|
|
67
|
+
line = self.lines.get(timeout=timeout)
|
|
68
|
+
except queue.Empty as e:
|
|
69
|
+
raise TimeoutError(f"no response within {timeout}s") from e
|
|
70
|
+
if line is None:
|
|
71
|
+
err = self.proc.stderr.read() if self.proc.stderr else ""
|
|
72
|
+
raise RuntimeError(f"judge command exited: {err.strip()[-500:]}")
|
|
73
|
+
return json.loads(line)
|
|
74
|
+
|
|
75
|
+
def close(self) -> int:
|
|
76
|
+
if self.proc.stdin:
|
|
77
|
+
self.proc.stdin.close()
|
|
78
|
+
try:
|
|
79
|
+
return self.proc.wait(timeout=10)
|
|
80
|
+
except subprocess.TimeoutExpired:
|
|
81
|
+
self.proc.kill()
|
|
82
|
+
return -9
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def judge_run(
|
|
86
|
+
workspace: str | Path,
|
|
87
|
+
commands: dict[str, str],
|
|
88
|
+
enable: bool = False,
|
|
89
|
+
timeout: float = 120.0,
|
|
90
|
+
resume: bool = True,
|
|
91
|
+
) -> dict[str, Any]:
|
|
92
|
+
if not plugin_enabled(enable):
|
|
93
|
+
raise PluginDisabled(
|
|
94
|
+
"the judge plugin is off by default; pass --enable-judge-plugin or set "
|
|
95
|
+
f"{ENV_FLAG}=1 to let eval-builder run the judge command you provide"
|
|
96
|
+
)
|
|
97
|
+
ws = Workspace.at(workspace)
|
|
98
|
+
if not ws.judge_requests.exists():
|
|
99
|
+
raise FileNotFoundError(f"{ws.judge_requests} not found; run `eval-builder judge-plan`")
|
|
100
|
+
requests = list(read_jsonl(ws.judge_requests))
|
|
101
|
+
done: set[str] = set()
|
|
102
|
+
if resume and ws.judgments.exists():
|
|
103
|
+
done = {r["request_id"] for r in read_jsonl(ws.judgments) if "request_id" in r}
|
|
104
|
+
elif ws.judgments.exists():
|
|
105
|
+
ws.judgments.unlink()
|
|
106
|
+
log: dict[str, Any] = {"started": datetime.now(UTC).isoformat(), "judges": {}}
|
|
107
|
+
with open(ws.judgments, "a", encoding="utf-8") as out:
|
|
108
|
+
for jid, cmd in commands.items():
|
|
109
|
+
todo = [r for r in requests if r["judge"] == jid and r["request_id"] not in done]
|
|
110
|
+
entry: dict[str, Any] = {
|
|
111
|
+
"command": cmd,
|
|
112
|
+
"requests": len(todo),
|
|
113
|
+
"ok": 0,
|
|
114
|
+
"errors": 0,
|
|
115
|
+
"seconds": 0.0,
|
|
116
|
+
}
|
|
117
|
+
log["judges"][jid] = entry
|
|
118
|
+
if not todo:
|
|
119
|
+
continue
|
|
120
|
+
t0 = time.monotonic()
|
|
121
|
+
proc = _LineProcess(shlex.split(cmd))
|
|
122
|
+
try:
|
|
123
|
+
for r in todo:
|
|
124
|
+
row = {k: v for k, v in r.items() if k not in ("presented", "prompt")}
|
|
125
|
+
try:
|
|
126
|
+
resp = proc.ask(r, timeout)
|
|
127
|
+
row["verdict"] = resp.get("verdict")
|
|
128
|
+
# keep extra plugin fields (raw text, token counts) as evidence
|
|
129
|
+
row.update({k: v for k, v in resp.items() if k not in row})
|
|
130
|
+
entry["ok"] += 1
|
|
131
|
+
except (TimeoutError, RuntimeError, json.JSONDecodeError) as e:
|
|
132
|
+
row["verdict"] = None
|
|
133
|
+
row["error"] = str(e)
|
|
134
|
+
entry["errors"] += 1
|
|
135
|
+
if isinstance(e, RuntimeError):
|
|
136
|
+
out.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
137
|
+
break
|
|
138
|
+
out.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
139
|
+
out.flush()
|
|
140
|
+
finally:
|
|
141
|
+
entry["exit_code"] = proc.close()
|
|
142
|
+
entry["seconds"] = round(time.monotonic() - t0, 2)
|
|
143
|
+
missing = sorted({r["judge"] for r in requests} - set(commands))
|
|
144
|
+
log["finished"] = datetime.now(UTC).isoformat()
|
|
145
|
+
log["judges_without_command"] = missing
|
|
146
|
+
log_path = ws.root / "judge_run_log.json"
|
|
147
|
+
runs = read_json(log_path).get("runs", []) if log_path.exists() else []
|
|
148
|
+
write_json(log_path, {"runs": [*runs, log]})
|
|
149
|
+
return log
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Plain statistics used by judge-check. Kept small so tests can verify them by hand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
|
|
9
|
+
Z95 = 1.959963984540054
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def wilson(k: int, n: int, z: float = Z95) -> tuple[float, float] | None:
|
|
13
|
+
"""Wilson score interval for a binomial proportion k/n."""
|
|
14
|
+
if n <= 0:
|
|
15
|
+
return None
|
|
16
|
+
p = k / n
|
|
17
|
+
denom = 1 + z * z / n
|
|
18
|
+
centre = (p + z * z / (2 * n)) / denom
|
|
19
|
+
half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
|
|
20
|
+
return (max(0.0, centre - half), min(1.0, centre + half))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cohen_kappa(a: Sequence[str], b: Sequence[str]) -> tuple[float | None, float, float]:
|
|
24
|
+
"""Return (kappa, observed agreement po, chance agreement pe). kappa is None if pe == 1."""
|
|
25
|
+
if len(a) != len(b):
|
|
26
|
+
raise ValueError("rating lists must have the same length")
|
|
27
|
+
n = len(a)
|
|
28
|
+
if n == 0:
|
|
29
|
+
return None, 0.0, 0.0
|
|
30
|
+
po = sum(x == y for x, y in zip(a, b, strict=True)) / n
|
|
31
|
+
ca, cb = Counter(a), Counter(b)
|
|
32
|
+
pe = sum(ca[label] * cb[label] for label in set(ca) | set(cb)) / (n * n)
|
|
33
|
+
if pe >= 1.0:
|
|
34
|
+
return None, po, pe
|
|
35
|
+
return (po - pe) / (1 - pe), po, pe
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def kappa_interval(po: float, pe: float, n: int, z: float = Z95) -> tuple[float, float] | None:
|
|
39
|
+
"""Approximate 95% interval using Cohen's (1960) large-sample standard error."""
|
|
40
|
+
if n <= 0 or pe >= 1.0:
|
|
41
|
+
return None
|
|
42
|
+
kappa = (po - pe) / (1 - pe)
|
|
43
|
+
se = math.sqrt(po * (1 - po) / (n * (1 - pe) ** 2))
|
|
44
|
+
return (max(-1.0, kappa - z * se), min(1.0, kappa + z * se))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def majority(labels: Sequence[str]) -> tuple[str | None, float]:
|
|
48
|
+
"""Most common label and its share. Label is None when the top count is tied."""
|
|
49
|
+
if not labels:
|
|
50
|
+
return None, 0.0
|
|
51
|
+
counts = Counter(labels).most_common()
|
|
52
|
+
top, cnt = counts[0]
|
|
53
|
+
if len(counts) > 1 and counts[1][1] == cnt:
|
|
54
|
+
return None, cnt / len(labels)
|
|
55
|
+
return top, cnt / len(labels)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def majority_vote_stability(counts: Mapping[str, int], k: int = 3) -> float | None:
|
|
59
|
+
"""Chance that a majority vote over k calls (drawn without replacement from the
|
|
60
|
+
observed trials) returns the same label as the majority over all trials.
|
|
61
|
+
|
|
62
|
+
Exact hypergeometric probability that the full-majority label gets more than half
|
|
63
|
+
of the k draws. None when there are fewer than k + 2 trials (too few to say
|
|
64
|
+
anything beyond the full vote) or no strict majority.
|
|
65
|
+
"""
|
|
66
|
+
n = sum(counts.values())
|
|
67
|
+
if n < k + 2 or not counts:
|
|
68
|
+
return None
|
|
69
|
+
ordered = sorted(counts.values(), reverse=True)
|
|
70
|
+
if len(ordered) > 1 and ordered[0] == ordered[1]:
|
|
71
|
+
return None
|
|
72
|
+
big = ordered[0]
|
|
73
|
+
need = k // 2 + 1
|
|
74
|
+
total = math.comb(n, k)
|
|
75
|
+
hits = sum(math.comb(big, x) * math.comb(n - big, k - x) for x in range(need, k + 1))
|
|
76
|
+
return hits / total
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""MCP server (stdio). Every tool returns the same JSON the CLI prints with --json.
|
|
2
|
+
|
|
3
|
+
The opt-in judge runner is deliberately not exposed here: it executes a command,
|
|
4
|
+
which should stay an explicit CLI action by the user.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import draft as draft_mod
|
|
12
|
+
from .export import export as export_fn
|
|
13
|
+
from .ingest import ingest as ingest_fn
|
|
14
|
+
from .judge.check import Thresholds
|
|
15
|
+
from .judge.check import judge_check as judge_check_fn
|
|
16
|
+
from .judge.plan import judge_plan as judge_plan_fn
|
|
17
|
+
from .report import build_report
|
|
18
|
+
from .select import select as select_fn
|
|
19
|
+
from .status import status as status_fn
|
|
20
|
+
|
|
21
|
+
INSTRUCTIONS = (
|
|
22
|
+
"eval-builder turns an app's logs into an eval suite and checks LLM judges. Workflow: "
|
|
23
|
+
"ingest -> select -> draft (you fill expected_behavior and criteria with the user, via "
|
|
24
|
+
"update_case and set_rubric) -> validate -> judge_plan (run each request several times "
|
|
25
|
+
"with the user's judge, save judgments.jsonl) -> judge_check -> export -> report. "
|
|
26
|
+
"Never invent human labels; ask the user for them."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _server() -> Any:
|
|
31
|
+
try:
|
|
32
|
+
from mcp.server.mcpserver import MCPServer
|
|
33
|
+
|
|
34
|
+
return MCPServer("eval-builder", instructions=INSTRUCTIONS)
|
|
35
|
+
except ImportError:
|
|
36
|
+
from mcp.server.fastmcp import FastMCP # type: ignore[attr-defined] # mcp 1.x
|
|
37
|
+
|
|
38
|
+
return FastMCP("eval-builder", instructions=INSTRUCTIONS)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_server() -> Any:
|
|
42
|
+
mcp = _server()
|
|
43
|
+
|
|
44
|
+
@mcp.tool()
|
|
45
|
+
def ingest(
|
|
46
|
+
paths: list[str], workspace: str = "evalset", format: str | None = None, redact: bool = True
|
|
47
|
+
) -> dict[str, Any]:
|
|
48
|
+
"""Read traces (OpenAI chat JSONL, Anthropic messages, Langfuse export, OpenTelemetry
|
|
49
|
+
GenAI spans, or generic input/output JSONL), normalize and redact them."""
|
|
50
|
+
return ingest_fn(paths, workspace, format, redact)
|
|
51
|
+
|
|
52
|
+
@mcp.tool()
|
|
53
|
+
def select(
|
|
54
|
+
workspace: str = "evalset",
|
|
55
|
+
n: int = 50,
|
|
56
|
+
seed: int = 0,
|
|
57
|
+
clusters: int | None = None,
|
|
58
|
+
failure_share: float = 0.3,
|
|
59
|
+
near_dup_threshold: float = 0.9,
|
|
60
|
+
dedupe_on: str = "input",
|
|
61
|
+
stratify: list[str] | None = None,
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
"""Pick a diverse, representative set of traces and record why each was picked."""
|
|
64
|
+
r = select_fn(
|
|
65
|
+
workspace, n, seed, clusters, failure_share, near_dup_threshold, dedupe_on, stratify
|
|
66
|
+
)
|
|
67
|
+
return {k: v for k, v in r.items() if k != "selected"} | {
|
|
68
|
+
"selected": [
|
|
69
|
+
{"trace_id": s["trace_id"], "reasons": s["reasons"]} for s in r["selected"]
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@mcp.tool()
|
|
74
|
+
def draft(workspace: str = "evalset", suite: str = "eval-builder suite") -> dict[str, Any]:
|
|
75
|
+
"""Write cases.yaml and rubric.yaml skeletons from the selection (keeps filled cases)."""
|
|
76
|
+
return draft_mod.draft(workspace, suite)
|
|
77
|
+
|
|
78
|
+
@mcp.tool()
|
|
79
|
+
def list_cases(workspace: str = "evalset", status: str | None = None) -> list[dict[str, Any]]:
|
|
80
|
+
"""Return the cases in cases.yaml, optionally filtered by status."""
|
|
81
|
+
cases = draft_mod.load_cases(workspace).get("cases") or []
|
|
82
|
+
return [c for c in cases if status is None or c.get("status") == status]
|
|
83
|
+
|
|
84
|
+
@mcp.tool()
|
|
85
|
+
def update_case(
|
|
86
|
+
case_id: str,
|
|
87
|
+
workspace: str = "evalset",
|
|
88
|
+
expected_behavior: str | None = None,
|
|
89
|
+
criteria: list[str] | None = None,
|
|
90
|
+
reference_output: str | None = None,
|
|
91
|
+
compare_output: str | None = None,
|
|
92
|
+
status: str | None = None,
|
|
93
|
+
notes: str | None = None,
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
"""Fill in or change one case. status is draft, ready or dropped."""
|
|
96
|
+
fields = {
|
|
97
|
+
"expected_behavior": expected_behavior,
|
|
98
|
+
"criteria": criteria,
|
|
99
|
+
"reference_output": reference_output,
|
|
100
|
+
"compare_output": compare_output,
|
|
101
|
+
"status": status,
|
|
102
|
+
"notes": notes,
|
|
103
|
+
}
|
|
104
|
+
return draft_mod.update_case(
|
|
105
|
+
workspace, case_id, **{k: v for k, v in fields.items() if v is not None}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@mcp.tool()
|
|
109
|
+
def set_rubric(
|
|
110
|
+
criteria: list[dict[str, Any]],
|
|
111
|
+
judges: list[dict[str, Any]] | None = None,
|
|
112
|
+
workspace: str = "evalset",
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
"""Replace rubric.yaml. criteria: [{id, description, scale}]. judges: [{id, mode
|
|
115
|
+
(pointwise|pairwise), criteria, labels, prompt}]."""
|
|
116
|
+
return draft_mod.write_rubric(workspace, {"criteria": criteria, "judges": judges or []})
|
|
117
|
+
|
|
118
|
+
@mcp.tool()
|
|
119
|
+
def validate(workspace: str = "evalset") -> dict[str, Any]:
|
|
120
|
+
"""Check cases.yaml and rubric.yaml; ready cases must have no TODO left."""
|
|
121
|
+
return draft_mod.validate(workspace)
|
|
122
|
+
|
|
123
|
+
@mcp.tool()
|
|
124
|
+
def judge_plan(
|
|
125
|
+
workspace: str = "evalset",
|
|
126
|
+
judges: list[str] | None = None,
|
|
127
|
+
trials: int = 5,
|
|
128
|
+
probes: list[str] | None = None,
|
|
129
|
+
probe_trials: int = 3,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""Write judge_requests.jsonl: every judge call to make, repeated `trials` times, plus
|
|
132
|
+
optional probes (swap: answer order, pairwise only; pad: irrelevant length)."""
|
|
133
|
+
return judge_plan_fn(workspace, judges, trials, probes, probe_trials)
|
|
134
|
+
|
|
135
|
+
@mcp.tool()
|
|
136
|
+
def judge_check(
|
|
137
|
+
workspace: str = "evalset",
|
|
138
|
+
judgments: str | None = None,
|
|
139
|
+
labels: str | None = None,
|
|
140
|
+
max_flip_rate: float = 0.2,
|
|
141
|
+
min_kappa: float = 0.4,
|
|
142
|
+
min_labeled: int = 20,
|
|
143
|
+
min_cases: int = 10,
|
|
144
|
+
) -> dict[str, Any]:
|
|
145
|
+
"""Flip rate, human agreement (accuracy, kappa), position and verbosity probes, and a
|
|
146
|
+
verdict per judge: trustworthy, unstable, biased, misaligned or not_enough_data."""
|
|
147
|
+
th = Thresholds(
|
|
148
|
+
max_flip_rate=max_flip_rate,
|
|
149
|
+
min_kappa=min_kappa,
|
|
150
|
+
min_labeled=min_labeled,
|
|
151
|
+
min_cases=min_cases,
|
|
152
|
+
)
|
|
153
|
+
r = judge_check_fn(workspace, judgments, labels, th)
|
|
154
|
+
return {k: v for k, v in r.items() if k != "judges"}
|
|
155
|
+
|
|
156
|
+
@mcp.tool()
|
|
157
|
+
def export(workspace: str = "evalset", formats: list[str] | None = None) -> dict[str, Any]:
|
|
158
|
+
"""Export ready cases to promptfoo, deepeval, inspect and/or jsonl."""
|
|
159
|
+
return export_fn(workspace, formats)
|
|
160
|
+
|
|
161
|
+
@mcp.tool()
|
|
162
|
+
def report(workspace: str = "evalset", title: str | None = None) -> dict[str, Any]:
|
|
163
|
+
"""Write report.md and report.json covering every step that has run."""
|
|
164
|
+
return build_report(workspace, title)
|
|
165
|
+
|
|
166
|
+
@mcp.tool()
|
|
167
|
+
def status(workspace: str = "evalset") -> dict[str, Any]:
|
|
168
|
+
"""Which steps have run in this workspace and what to do next."""
|
|
169
|
+
return status_fn(workspace)
|
|
170
|
+
|
|
171
|
+
return mcp
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main() -> None:
|
|
175
|
+
build_server().run("stdio")
|
eval_builder/redact.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Default-on redaction of obvious secrets and PII.
|
|
2
|
+
|
|
3
|
+
Patterns are deliberately conservative: they catch common key formats, emails,
|
|
4
|
+
card numbers that pass a Luhn check, US-style phone numbers with separators, and
|
|
5
|
+
SSN-shaped numbers. They are not a DLP system; the report lists exactly what was
|
|
6
|
+
replaced so you can check.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|
16
|
+
(
|
|
17
|
+
"private_key",
|
|
18
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----"),
|
|
19
|
+
),
|
|
20
|
+
("anthropic_key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}")),
|
|
21
|
+
("openai_key", re.compile(r"\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}")),
|
|
22
|
+
("aws_access_key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
|
|
23
|
+
("github_token", re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})")),
|
|
24
|
+
("slack_token", re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}")),
|
|
25
|
+
("google_api_key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
|
|
26
|
+
("stripe_key", re.compile(r"\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}")),
|
|
27
|
+
("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
|
|
28
|
+
("bearer_token", re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/-]{20,}=*")),
|
|
29
|
+
("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
|
|
30
|
+
("ssn", re.compile(r"\b\d{3}-\d{2}-\d{4}\b")),
|
|
31
|
+
("phone", re.compile(r"(?<![\d-])(?:\+1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?![\d-])")),
|
|
32
|
+
("credit_card", re.compile(r"\b\d(?:[ -]?\d){12,18}\b")),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _luhn_ok(digits: str) -> bool:
|
|
37
|
+
total = 0
|
|
38
|
+
for i, ch in enumerate(reversed(digits)):
|
|
39
|
+
d = int(ch)
|
|
40
|
+
if i % 2 == 1:
|
|
41
|
+
d *= 2
|
|
42
|
+
if d > 9:
|
|
43
|
+
d -= 9
|
|
44
|
+
total += d
|
|
45
|
+
return total % 10 == 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Redactor:
|
|
49
|
+
"""Replaces matches with [REDACTED_<KIND>] and counts what it replaced."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, enabled: bool = True) -> None:
|
|
52
|
+
self.enabled = enabled
|
|
53
|
+
self.counts: Counter[str] = Counter()
|
|
54
|
+
self.by_trace: dict[str, Counter[str]] = {}
|
|
55
|
+
self._current: str | None = None
|
|
56
|
+
|
|
57
|
+
def start_trace(self, trace_id: str) -> None:
|
|
58
|
+
self._current = trace_id
|
|
59
|
+
|
|
60
|
+
def text(self, s: str, count: bool = True) -> str:
|
|
61
|
+
"""Redact one string. count=False redacts text already counted elsewhere."""
|
|
62
|
+
if not self.enabled or not s:
|
|
63
|
+
return s
|
|
64
|
+
for kind, pat in _PATTERNS:
|
|
65
|
+
|
|
66
|
+
def repl(m: re.Match[str], kind: str = kind) -> str:
|
|
67
|
+
return self._replace(kind, m.group(0), count)
|
|
68
|
+
|
|
69
|
+
s = pat.sub(repl, s)
|
|
70
|
+
return s
|
|
71
|
+
|
|
72
|
+
def _replace(self, kind: str, match: str, count: bool = True) -> str:
|
|
73
|
+
if kind == "credit_card":
|
|
74
|
+
digits = re.sub(r"\D", "", match)
|
|
75
|
+
if not (13 <= len(digits) <= 19 and _luhn_ok(digits)):
|
|
76
|
+
return match
|
|
77
|
+
if not count:
|
|
78
|
+
return f"[REDACTED_{kind.upper()}]"
|
|
79
|
+
self.counts[kind] += 1
|
|
80
|
+
if self._current is not None:
|
|
81
|
+
self.by_trace.setdefault(self._current, Counter())[kind] += 1
|
|
82
|
+
return f"[REDACTED_{kind.upper()}]"
|
|
83
|
+
|
|
84
|
+
def value(self, v: Any) -> Any:
|
|
85
|
+
if isinstance(v, str):
|
|
86
|
+
return self.text(v)
|
|
87
|
+
if isinstance(v, list):
|
|
88
|
+
return [self.value(x) for x in v]
|
|
89
|
+
if isinstance(v, dict):
|
|
90
|
+
return {k: self.value(x) for k, x in v.items()}
|
|
91
|
+
return v
|
|
92
|
+
|
|
93
|
+
def summary(self) -> dict[str, Any]:
|
|
94
|
+
return {
|
|
95
|
+
"enabled": self.enabled,
|
|
96
|
+
"total": sum(self.counts.values()),
|
|
97
|
+
"by_kind": dict(sorted(self.counts.items())),
|
|
98
|
+
"traces_affected": len(self.by_trace),
|
|
99
|
+
"by_trace": {t: dict(c) for t, c in sorted(self.by_trace.items())},
|
|
100
|
+
"note": "Values are replaced with [REDACTED_<KIND>]; originals are never written.",
|
|
101
|
+
}
|