Skip to content

Commit 3360ef7

Browse files
committed
fix(evaluation): redact optimizer config snapshots
1 parent c9a7c87 commit 3360ef7

2 files changed

Lines changed: 189 additions & 8 deletions

File tree

tests/evaluation/test_agent_optimizer.py

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ async def test_facade_persists_artifacts_under_output_dir(tmp_path, monkeypatch)
254254
"""The facade must materialise result.json, summary.txt, rounds/*.json,
255255
baseline_prompts/, best_prompts/, config.snapshot.json and run.log under
256256
output_dir for every successful run."""
257+
import json
258+
257259
train = EvalSet(eval_set_id="train", eval_cases=[_eval_case("c1")])
258260
val = EvalSet(eval_set_id="val", eval_cases=[_eval_case("c1")])
259261
train_path = tmp_path / "train.json"
@@ -287,7 +289,14 @@ async def fake_call_gepa(self, **kwargs):
287289

288290
assert (output_dir / "result.json").is_file()
289291
assert (output_dir / "summary.txt").is_file()
290-
assert (output_dir / "config.snapshot.json").is_file()
292+
config_snapshot_path = output_dir / "config.snapshot.json"
293+
assert config_snapshot_path.is_file()
294+
config_snapshot_text = config_snapshot_path.read_text(encoding="utf-8")
295+
assert "test-key" not in config_snapshot_text
296+
config_snapshot = json.loads(config_snapshot_text)
297+
assert config_snapshot["optimize"]["algorithm"]["reflection_lm"][
298+
"api_key"
299+
] == "<redacted>"
291300
assert (output_dir / "run.log").is_file()
292301
assert (output_dir / "baseline_prompts" / "instruction.md").is_file()
293302
assert (output_dir / "best_prompts" / "instruction.md").is_file()
@@ -297,6 +306,114 @@ async def fake_call_gepa(self, **kwargs):
297306
assert "SUCCEEDED" in log_line
298307

299308

309+
def test_copy_config_snapshot_recursively_redacts_common_secret_keys(tmp_path):
310+
"""Config snapshots must remain useful without publishing credentials."""
311+
import json
312+
313+
payload = {
314+
"api_key": "api-secret",
315+
"nested": [
316+
{
317+
"TOKEN": "token-secret",
318+
"access-token": "access-secret",
319+
"Authorization": "Bearer auth-secret",
320+
},
321+
{
322+
"password": "password-secret",
323+
"credentials": {"username": "alice", "password": "nested-secret"},
324+
"privateKey": "private-secret",
325+
},
326+
{
327+
"openai_api_key": "namespaced-api-secret",
328+
"github_token": "namespaced-token-secret",
329+
"aws_secret_access_key": "aws-secret",
330+
"db_passwd": "database-secret",
331+
},
332+
],
333+
"model_name": "gpt-4o",
334+
"max_tokens": 128,
335+
"token_budget": 256,
336+
}
337+
config_path = tmp_path / "optimizer.json"
338+
config_path.write_text(json.dumps(payload), encoding="utf-8")
339+
output_dir = tmp_path / "output"
340+
output_dir.mkdir()
341+
342+
AgentOptimizer._copy_config_snapshot(str(config_path), str(output_dir))
343+
344+
snapshot_path = output_dir / "config.snapshot.json"
345+
snapshot_text = snapshot_path.read_text(encoding="utf-8")
346+
snapshot = json.loads(snapshot_text)
347+
assert snapshot == {
348+
"api_key": "<redacted>",
349+
"nested": [
350+
{
351+
"TOKEN": "<redacted>",
352+
"access-token": "<redacted>",
353+
"Authorization": "<redacted>",
354+
},
355+
{
356+
"password": "<redacted>",
357+
"credentials": "<redacted>",
358+
"privateKey": "<redacted>",
359+
},
360+
{
361+
"openai_api_key": "<redacted>",
362+
"github_token": "<redacted>",
363+
"aws_secret_access_key": "<redacted>",
364+
"db_passwd": "<redacted>",
365+
},
366+
],
367+
"model_name": "gpt-4o",
368+
"max_tokens": 128,
369+
"token_budget": 256,
370+
}
371+
assert snapshot_text == (
372+
json.dumps(
373+
snapshot,
374+
ensure_ascii=False,
375+
sort_keys=True,
376+
indent=2,
377+
allow_nan=False,
378+
)
379+
+ "\n"
380+
)
381+
for secret in (
382+
"api-secret",
383+
"token-secret",
384+
"access-secret",
385+
"auth-secret",
386+
"password-secret",
387+
"nested-secret",
388+
"private-secret",
389+
"namespaced-api-secret",
390+
"namespaced-token-secret",
391+
"aws-secret",
392+
"database-secret",
393+
):
394+
assert secret not in snapshot_text
395+
396+
397+
@pytest.mark.parametrize(
398+
"invalid_config",
399+
[
400+
'{"api_key": "secret", invalid}',
401+
'{"api_key": NaN}',
402+
],
403+
)
404+
def test_copy_config_snapshot_invalid_json_fails_closed(tmp_path, invalid_config):
405+
"""Malformed source config must never be copied verbatim as an artifact."""
406+
config_path = tmp_path / "optimizer.json"
407+
config_path.write_text(invalid_config, encoding="utf-8")
408+
output_dir = tmp_path / "output"
409+
output_dir.mkdir()
410+
411+
AgentOptimizer._copy_config_snapshot(str(config_path), str(output_dir))
412+
413+
assert not (output_dir / "config.snapshot.json").exists()
414+
assert not (output_dir / "config.snapshot.json.tmp").exists()
415+
416+
300417
@pytest.mark.asyncio
301418
async def test_facade_persists_artifacts_when_algorithm_fails(tmp_path, monkeypatch):
302419
"""Even when the algorithm returns a FAILED result the facade should

trpc_agent_sdk/evaluation/_agent_optimizer.py

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
import inspect
18+
import json
1819
import logging
1920
import os
2021
import signal
@@ -52,6 +53,56 @@
5253

5354
_PROMPT_FILE_LOGGER = logging.getLogger("trpc_agent_sdk.optimizer")
5455

56+
_REDACTED_CONFIG_VALUE = "<redacted>"
57+
_SENSITIVE_CONFIG_KEY_SUFFIXES = (
58+
"apikey",
59+
"authorization",
60+
"authtoken",
61+
"bearertoken",
62+
"clientsecret",
63+
"credential",
64+
"credentials",
65+
"idtoken",
66+
"password",
67+
"passwd",
68+
"privatekey",
69+
"refreshtoken",
70+
"secret",
71+
"secretaccesskey",
72+
"secretkey",
73+
"token",
74+
"accesstoken",
75+
)
76+
77+
78+
def _reject_non_finite_json_constant(value: str) -> Any:
79+
"""Reject Python's non-standard JSON constants (NaN and infinities)."""
80+
raise ValueError(f"non-finite JSON constant is not allowed: {value}")
81+
82+
83+
def _redact_config_secrets(value: Any) -> Any:
84+
"""Return a JSON-compatible copy with credential values redacted.
85+
86+
Key matching is case-insensitive, ignores separators and accepts
87+
namespaced credential suffixes, so ``api_key``, ``API-Key`` and
88+
``openai_api_key`` share the same policy without redacting safe fields
89+
such as ``max_tokens``.
90+
"""
91+
if isinstance(value, dict):
92+
redacted = {}
93+
for key, child in value.items():
94+
normalized_key = "".join(
95+
character for character in key.casefold() if character.isalnum()
96+
)
97+
if normalized_key.endswith(_SENSITIVE_CONFIG_KEY_SUFFIXES):
98+
redacted[key] = _REDACTED_CONFIG_VALUE
99+
else:
100+
redacted[key] = _redact_config_secrets(child)
101+
return redacted
102+
if isinstance(value, list):
103+
return [_redact_config_secrets(child) for child in value]
104+
return value
105+
55106

56107
def _atomic_write_text(path: str, content: str) -> None:
57108
"""Atomically replace ``path`` with ``content`` (UTF-8).
@@ -157,8 +208,8 @@ async def optimize(
157208
output_dir: Required artifact directory. The facade creates it when
158209
missing and persists ``result.json``, ``summary.txt``,
159210
``rounds/`` records, ``baseline_prompts/`` and ``best_prompts/``
160-
directories, a ``config.snapshot.json`` copy of the input
161-
config, and a ``run.log`` summary line.
211+
directories, a redacted ``config.snapshot.json`` copy of the
212+
input config, and a ``run.log`` summary line.
162213
callbacks: Optional evaluator lifecycle callbacks.
163214
update_source: When True, persist the best candidate back to
164215
every registered TargetPrompt field after a SUCCEEDED
@@ -393,7 +444,7 @@ def _persist_artifacts(
393444
(regardless of update_source).
394445
- ``best_prompts/<name>.md`` Best candidate per field
395446
(only when a result was produced).
396-
- ``config.snapshot.json`` Copy of the input config.
447+
- ``config.snapshot.json`` Redacted copy of the input config.
397448
- ``run.log`` One-line status footer.
398449
399450
SIGINT (Ctrl+C) is masked for the duration of this method so a
@@ -473,12 +524,25 @@ def _write_rounds_directory(result: OptimizeResult, output_dir: str) -> None:
473524
def _copy_config_snapshot(config_path: str, output_dir: str) -> None:
474525
target = os.path.join(output_dir, "config.snapshot.json")
475526
try:
476-
# Read + atomic-write rather than shutil.copyfile so an interrupted
477-
# copy cannot leave a half-written ``config.snapshot.json``.
478-
content = Path(config_path).read_text(encoding="utf-8")
527+
# Parse and redact fully before the first write so neither the
528+
# destination nor its temporary sibling can ever contain the raw
529+
# credential-bearing config. Invalid/non-finite JSON therefore
530+
# fails closed instead of falling back to a plaintext copy.
531+
raw_config = json.loads(
532+
Path(config_path).read_text(encoding="utf-8"),
533+
parse_constant=_reject_non_finite_json_constant,
534+
)
535+
redacted_config = _redact_config_secrets(raw_config)
536+
content = json.dumps(
537+
redacted_config,
538+
ensure_ascii=False,
539+
sort_keys=True,
540+
indent=2,
541+
allow_nan=False,
542+
) + "\n"
479543
_atomic_write_text(target, content)
480544
except Exception: # pragma: no cover
481-
_PROMPT_FILE_LOGGER.warning("failed to copy config snapshot", exc_info=True)
545+
_PROMPT_FILE_LOGGER.warning("failed to write redacted config snapshot", exc_info=True)
482546

483547
@staticmethod
484548
def _write_run_log(*, output_dir: str, line: str) -> None:

0 commit comments

Comments
 (0)