Skip to content

Commit 94618bc

Browse files
committed
fix(evaluation): 加固报告发布与配置快照
1 parent cc54f82 commit 94618bc

2 files changed

Lines changed: 228 additions & 11 deletions

File tree

examples/optimization/eval_optimize_loop/artifact_writer.py

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@
77

88
from __future__ import annotations
99

10+
import ctypes
11+
import errno
1012
import hashlib
13+
import json
1114
import os
1215
from pathlib import Path
1316
import shutil
14-
from typing import Literal, TypeAlias
17+
import sys
18+
from typing import Callable, Literal, TypeAlias
1519
from uuid import uuid4
1620

1721
from pydantic import BaseModel
@@ -31,6 +35,20 @@
3135
]
3236

3337
_INPUT_COPY_DISABLED = "artifacts.copy_input_files=false"
38+
_SENSITIVE_CONFIG_KEYS = {"apikey", "authorization", "baseurl"}
39+
_APPROVED_SENSITIVE_VALUES = {
40+
"",
41+
"${TRPC_AGENT_API_KEY}",
42+
"${TRPC_AGENT_BASE_URL}",
43+
"fake-not-used-in-offline-mode",
44+
}
45+
_AT_FDCWD = -100
46+
_RENAME_NOREPLACE = 1
47+
_RENAMEAT2_UNAVAILABLE = {
48+
errno.ENOSYS,
49+
errno.EINVAL,
50+
getattr(errno, "EOPNOTSUPP", errno.ENOTSUP),
51+
}
3452

3553

3654
class ArtifactWriteError(RuntimeError):
@@ -126,6 +144,87 @@ def _json_text(model: BaseModel) -> str:
126144
return model.model_dump_json(by_alias=False, indent=2) + "\n"
127145

128146

147+
def _normalized_sensitive_key(key: str) -> str:
148+
return key.replace("_", "").replace("-", "").casefold()
149+
150+
151+
def _validate_sensitive_config_values(value: object, *, path: str = "$") -> None:
152+
if isinstance(value, list):
153+
for index, item in enumerate(value):
154+
_validate_sensitive_config_values(item, path=f"{path}[{index}]")
155+
return
156+
if not isinstance(value, dict):
157+
return
158+
for key, item in value.items():
159+
item_path = f"{path}.{key}"
160+
if _normalized_sensitive_key(key) in _SENSITIVE_CONFIG_KEYS:
161+
if not isinstance(item, str) or item not in _APPROVED_SENSITIVE_VALUES:
162+
raise ArtifactWriteError(
163+
"sensitive optimizer config value is not an approved "
164+
f"placeholder: {item_path}"
165+
)
166+
else:
167+
_validate_sensitive_config_values(item, path=item_path)
168+
169+
170+
def _validate_optimizer_config_for_copy(path: Path) -> None:
171+
try:
172+
payload = json.loads(path.read_text(encoding="utf-8"))
173+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
174+
raise ArtifactWriteError(
175+
f"failed to parse optimizer config snapshot: {path}: {exc}"
176+
) from exc
177+
_validate_sensitive_config_values(payload)
178+
179+
180+
def _target_exists(path: Path) -> bool:
181+
return path.exists() or path.is_symlink()
182+
183+
184+
def _rename_directory_no_replace(source: Path, target: Path) -> None:
185+
"""Atomically publish a directory without replacing an existing target.
186+
187+
Linux uses renameat2 with RENAME_NOREPLACE. Platforms without that primitive
188+
use a controlled fallback that rechecks the target immediately before rename.
189+
"""
190+
if sys.platform.startswith("linux"):
191+
try:
192+
libc = ctypes.CDLL(None, use_errno=True)
193+
renameat2 = libc.renameat2
194+
except (AttributeError, OSError):
195+
renameat2 = None
196+
if renameat2 is not None:
197+
renameat2.argtypes = [
198+
ctypes.c_int,
199+
ctypes.c_char_p,
200+
ctypes.c_int,
201+
ctypes.c_char_p,
202+
ctypes.c_uint,
203+
]
204+
renameat2.restype = ctypes.c_int
205+
ctypes.set_errno(0)
206+
result = renameat2(
207+
_AT_FDCWD,
208+
os.fsencode(source),
209+
_AT_FDCWD,
210+
os.fsencode(target),
211+
_RENAME_NOREPLACE,
212+
)
213+
if result == 0:
214+
return
215+
error_number = ctypes.get_errno()
216+
if error_number == errno.EEXIST:
217+
raise ArtifactWriteError(
218+
f"report directory already exists: {target}"
219+
)
220+
if error_number not in _RENAMEAT2_UNAVAILABLE:
221+
raise OSError(error_number, os.strerror(error_number), target)
222+
223+
if _target_exists(target):
224+
raise ArtifactWriteError(f"report directory already exists: {target}")
225+
source.rename(target)
226+
227+
129228
def _published_relative_path(root: Path, path: Path) -> str:
130229
relative = path.relative_to(root)
131230
if relative.parts and relative.parts[0].startswith(".report.tmp-"):
@@ -216,6 +315,7 @@ def _copy_input(
216315
destination_name: str,
217316
artifact_id: str,
218317
produced_by: ReportPhase,
318+
content_validator: Callable[[Path], None] | None = None,
219319
) -> ArtifactReference:
220320
if source.is_symlink():
221321
raise ArtifactWriteError(f"input must not be a symbolic link: {source}")
@@ -225,6 +325,8 @@ def _copy_input(
225325
raise ArtifactWriteError(f"failed to read input {source}: {exc}") from exc
226326
if actual_sha256 != expected_sha256:
227327
raise ArtifactWriteError(f"input hash mismatch: {source}")
328+
if content_validator is not None:
329+
content_validator(source)
228330

229331
destination = staging / "inputs" / destination_name
230332
destination.parent.mkdir(parents=True, exist_ok=True)
@@ -387,6 +489,11 @@ def publish_report_bundle(
387489
)
388490
for artifact_id, source, expected_hash, destination_name, produced_by in input_specs:
389491
if copy_input_files:
492+
content_validator = (
493+
_validate_optimizer_config_for_copy
494+
if artifact_id == "input.optimizer_config"
495+
else None
496+
)
390497
references.append(
391498
_copy_input(
392499
root=root,
@@ -396,6 +503,7 @@ def publish_report_bundle(
396503
destination_name=destination_name,
397504
artifact_id=artifact_id,
398505
produced_by=produced_by,
506+
content_validator=content_validator,
399507
)
400508
)
401509
else:
@@ -433,7 +541,7 @@ def publish_report_bundle(
433541
)
434542
_validate_available_references(root, staging, validated_index)
435543

436-
staging.rename(target)
544+
_rename_directory_no_replace(staging, target)
437545
staging = None
438546
return validated_index
439547
except Exception as exc:

tests/evaluation/test_eval_optimize_loop_stage5_artifacts.py

Lines changed: 118 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from datetime import datetime, timezone
44
from hashlib import sha256
5+
import json
56
from pathlib import Path
67
import shutil
78

@@ -32,8 +33,7 @@ def _copy_example(tmp_path: Path) -> Path:
3233
return target
3334

3435

35-
async def _build_fake_report(tmp_path: Path, run_id: str):
36-
root = _copy_example(tmp_path)
36+
async def _build_fake_report_for_root(root: Path, run_id: str):
3737
prepared = prepare_run(root / "pipeline.json", run_id=run_id)
3838
result = await run_fake_stage(prepared, scenario="improve")
3939
shutil.rmtree(Path(prepared.workspace.run_dir) / "report", ignore_errors=True)
@@ -60,6 +60,10 @@ async def _build_fake_report(tmp_path: Path, run_id: str):
6060
return prepared, report
6161

6262

63+
async def _build_fake_report(tmp_path: Path, run_id: str):
64+
return await _build_fake_report_for_root(_copy_example(tmp_path), run_id)
65+
66+
6367
@pytest.mark.asyncio
6468
async def test_publish_report_bundle_materializes_and_indexes_required_files(tmp_path):
6569
prepared, report = await _build_fake_report(tmp_path, "artifact_complete")
@@ -71,15 +75,62 @@ async def test_publish_report_bundle_materializes_and_indexes_required_files(tmp
7175
assert (report_dir / "optimization_report.json").is_file()
7276
assert (report_dir / "optimization_report.md").is_file()
7377
assert (report_dir / "artifact_index.json").is_file()
74-
for name in (
75-
"baseline_train",
76-
"baseline_validation",
77-
"candidate_train",
78-
"candidate_validation",
79-
):
80-
assert (report_dir / "evaluations" / f"{name}.json").is_file()
8178
assert all(ref.relative_path != "report/artifact_index.json" for ref in index.artifacts)
8279

80+
input_paths = {
81+
ref.artifact_id: ref.relative_path
82+
for ref in index.artifacts
83+
if ref.artifact_type == "input"
84+
}
85+
assert input_paths == {
86+
"input.pipeline_config": "report/inputs/pipeline_config.json",
87+
"input.optimizer_config": "report/inputs/optimizer_config.json",
88+
"input.train_evalset": "report/inputs/train_evalset.json",
89+
"input.validation_evalset": "report/inputs/validation_evalset.json",
90+
}
91+
assert {
92+
ref.artifact_id: ref.relative_path
93+
for ref in index.artifacts
94+
if ref.artifact_type == "prompt"
95+
} == {
96+
"prompt.baseline.system_prompt": (
97+
"report/prompts/baseline/000-system_prompt.md"
98+
),
99+
"prompt.candidate.system_prompt": (
100+
"report/prompts/candidate/000-system_prompt.md"
101+
),
102+
}
103+
baseline_prompt = report_dir / "prompts" / "baseline" / "000-system_prompt.md"
104+
candidate_prompt = report_dir / "prompts" / "candidate" / "000-system_prompt.md"
105+
assert baseline_prompt.read_text(encoding="utf-8") == (
106+
report.input_snapshot.prompt_snapshots[0].content
107+
)
108+
assert candidate_prompt.read_text(encoding="utf-8") == (
109+
report.candidate.prompts["system_prompt"]
110+
)
111+
evaluation_paths = {
112+
ref.artifact_id: ref.relative_path
113+
for ref in index.artifacts
114+
if ref.artifact_type == "evaluation"
115+
}
116+
assert evaluation_paths == {
117+
f"evaluation.{name}": f"report/evaluations/{name}.json"
118+
for name in (
119+
"baseline_train",
120+
"baseline_validation",
121+
"candidate_train",
122+
"candidate_validation",
123+
)
124+
}
125+
assert {
126+
path.name for path in (report_dir / "evaluations").iterdir()
127+
} == {
128+
"baseline_train.json",
129+
"baseline_validation.json",
130+
"candidate_train.json",
131+
"candidate_validation.json",
132+
}
133+
83134
available = [ref for ref in index.artifacts if ref.status == "available"]
84135
assert available
85136
for ref in available:
@@ -110,6 +161,36 @@ async def test_publish_rejects_input_hash_drift(tmp_path):
110161
assert list(run_dir.glob(".report.tmp-*")) == []
111162

112163

164+
@pytest.mark.asyncio
165+
async def test_publish_rejects_plaintext_secret_in_optimizer_config_without_leaking_it(
166+
tmp_path,
167+
):
168+
root = _copy_example(tmp_path)
169+
optimizer_path = root / "optimizer.json"
170+
payload = json.loads(optimizer_path.read_text(encoding="utf-8"))
171+
secret = "sk-real-sentinel-secret-must-not-be-copied"
172+
payload["optimize"]["algorithm"]["reflection_lm"]["api_key"] = secret
173+
optimizer_path.write_text(
174+
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
175+
encoding="utf-8",
176+
)
177+
prepared, report = await _build_fake_report_for_root(root, "artifact_secret")
178+
run_dir = Path(prepared.workspace.run_dir)
179+
180+
with pytest.raises(ArtifactWriteError, match="sensitive optimizer config"):
181+
publish_report_bundle(report, run_dir=run_dir, copy_input_files=True)
182+
183+
assert secret in optimizer_path.read_text(encoding="utf-8")
184+
assert not (run_dir / "report").exists()
185+
assert list(run_dir.glob(".report.tmp-*")) == []
186+
secret_bytes = secret.encode("utf-8")
187+
assert all(
188+
secret_bytes not in path.read_bytes()
189+
for path in run_dir.rglob("*")
190+
if path.is_file()
191+
)
192+
193+
113194
@pytest.mark.asyncio
114195
async def test_publish_rejects_existing_report_directory(tmp_path):
115196
prepared, report = await _build_fake_report(tmp_path, "artifact_existing")
@@ -124,6 +205,34 @@ async def test_publish_rejects_existing_report_directory(tmp_path):
124205
)
125206

126207

208+
@pytest.mark.asyncio
209+
async def test_publish_does_not_replace_report_directory_created_during_staging(
210+
tmp_path, monkeypatch
211+
):
212+
prepared, report = await _build_fake_report(tmp_path, "artifact_report_race")
213+
run_dir = Path(prepared.workspace.run_dir)
214+
report_dir = run_dir / "report"
215+
original_write = artifact_writer._write_text
216+
217+
def create_competing_report_before_publish(path, content):
218+
original_write(path, content)
219+
if path.name == "artifact_index.json":
220+
report_dir.mkdir()
221+
222+
monkeypatch.setattr(
223+
artifact_writer,
224+
"_write_text",
225+
create_competing_report_before_publish,
226+
)
227+
228+
with pytest.raises(ArtifactWriteError, match="already exists"):
229+
publish_report_bundle(report, run_dir=run_dir, copy_input_files=True)
230+
231+
assert report_dir.is_dir()
232+
assert list(report_dir.iterdir()) == []
233+
assert list(run_dir.glob(".report.tmp-*")) == []
234+
235+
127236
@pytest.mark.parametrize("target_inside_run", [False, True])
128237
def test_discovery_rejects_any_file_symlink(tmp_path, target_inside_run):
129238
run_dir = tmp_path / "run"

0 commit comments

Comments
 (0)