Skip to content

fix(evals): keep non-secret environment values out of telemetry redaction - #29

Merged
Guo-Yixin merged 2 commits into
mainfrom
fix/telemetry-redact-non-secret-env-values
Oct 1, 2026
Merged

Guo-Yixin merged 2 commits into
mainfrom
fix/telemetry-redact-non-secret-env-values

Conversation

@Guo-Yixin

@Guo-Yixin Guo-Yixin commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Fixes: 无独立跟踪 issue。本问题是排查 #16 时发现的另一个根因,与 #16 / #18 的 PostgreSQL 隔离改动相互独立、互不重叠。

Why

排查 #16(配置了 POSTGRES_DSN 会让默认单测去连 PostgreSQL)时,除了 issue 里记录的 2 个失败,还多出一个与 PostgreSQL 无关的失败:

tests/unit/test_hybrid_search.py::test_eval_search_is_bound_to_the_case_repository

触发它的不是数据库连接,而是 telemetry 的脱敏逻辑:

agent/evals/telemetry.py::_redact() 把变量名里含有 API_KEY / _TOKEN / _SECRET / _PASSWORD / _DSN 的环境变量一律当成密钥,然后用 str.replace() 把它的整个取值在 payload 的每个字符串里做全局替换。

只要环境里存在一个并非密钥的开关(例如 X_API_KEY_HELPER_DISABLED=1),取值就是 1,于是 payload 中任何位置的 1 都会变成 [REDACTED]:

.../pytest-201/projects/target-repo  →  .../pytest-20[REDACTED]/projects/target-repo

实测 _redact():输入 38 字符 → 输出 47 字符(每个 1 多出 9 个字符)。

后果有两层,第二层比第一层更严重:

  1. eval 证据被静默改写。repo_path、工作区路径、ID、计数等只要含该字符就会失真。因为 pytest 临时目录带编号,还表现为 flaky 单测:编号含 1 时红、不含时绿。

  2. 真正的密钥反而没被完整脱敏。替换按环境变量顺序逐一执行,短取值先把文本改掉,长密钥随后就匹配不上了。实测:

    POSTGRES_DSN=postgresql://eval:sup3rsecret@127.0.0.1:5432/eval_db

    被记成 postgresql://eval:sup3rsecret@[REDACTED]27.0.0.[REDACTED]:5432/eval_db —— 密码明文保留,只有主机里的 1 被替换。

What changed

agent/evals/telemetry.py:环境变量脱敏不再做无边界的全局替换。

  • 仅当变量名命中标记**且取值长度 ≥ _ENV_SECRET_MIN_LENGTH(当前为 8)**时才视为密钥材料;1 / 0 / true / no 这类开关取值不再参与替换。
  • 改为单趟正则替换,并要求完整标识符边界((?<![A-Za-z0-9_])…(?![A-Za-z0-9_])),长取值优先。因此 postgres 不会改写 postgresql、postgres_user、postgres_backup,而独立出现的真实密钥仍整体命中。
  • 下划线算标识符字符,连字符不算:下划线属于标识符内部(postgres_user),连字符在路径和命令行里是分隔符,仍算合法边界。
  • _SECRET_RE 行为不变:它只覆盖无引号的具名取值(password=s3cr3t → password=[REDACTED])。{"password": "s3cr3t"} 匹配不到,因为引号挡在标记名与冒号之间。

短值契约(显式说明,避免误解):短于阈值的取值不再作为环境变量密钥参与替换,任何形式都不替换——包括字典取值与 JSON 字符串:

# REVIEW_PASSWORD=s3cr3t(7 字符,低于阈值)
_redact({"password": "s3cr3t"})        # → {"password": "s3cr3t"}      保持原样
_redact('{"password": "s3cr3t"}')      # → '{"password": "s3cr3t"}'    保持原样
_redact("password=s3cr3t")             # → "password=[REDACTED]"       仍被 _SECRET_RE 拦截

这是有意的契约选择:短取值无法在不误伤 payload 的前提下安全替换。一个取值算不算"有效密钥"由长度阈值决定,而不是由它出现在哪种结构里决定。需要保护的凭据应达到阈值长度,测试也用 ≥ 8 字符的夹具验证。

新增 tests/unit/test_eval_telemetry.py(8 个用例):开关取值不污染 payload、record_eval_event 落盘路径不被改写、真实 DSN 仍整体脱敏、长密钥只在标识符边界替换、下划线复合标识符不被切断、短值三种形式保持原样、阈值长度密钥在嵌套 payload 中仍被脱敏、无引号具名短值仍被拦截。

Surface area

  • Frontend UI
  • Backend API
  • Agents / LangGraph
  • Sandbox
  • Skills
  • Dependencies
  • Default behavior change —— 仅限 eval telemetry 的脱敏结果
  • Docs / tests / CI only —— 新增单测;无 CI 配置改动

未改动运行时 Agent 链路、持久化访问与 prompt;未新增依赖;未改跨语言契约,不新增状态枚举。

Screenshots

不涉及 Frontend UI。

Bug fix verification

新增回归测试(先红后绿):tests/unit/test_eval_telemetry.py::test_recorded_event_keeps_workspace_path_intact

  • main(1a7ad4a)上红:4 failed, 1 passed
  • 本分支上绿:8 passed

原失败用例端到端验证:tests/unit/test_hybrid_search.py::test_eval_search_is_bound_to_the_case_repository

该失败与 pytest 临时目录编号耦合,因此用 --basetemp 含 1 的路径固定为确定性复现:

# main(1a7ad4a)→ 1 failed
CODEBUDDY_API_KEY_HELPER_DISABLED=1 python -m pytest \
  tests/unit/test_hybrid_search.py::test_eval_search_is_bound_to_the_case_repository -q \
  --basetemp=<tmp>/bt-base-1

# 本分支 → 1 passed
CODEBUDDY_API_KEY_HELPER_DISABLED=1 python -m pytest \
  tests/unit/test_hybrid_search.py::test_eval_search_is_bound_to_the_case_repository -q \
  --basetemp=<tmp>/bt-fix-1

另用最小化对照实验确认根因:把 CODEBUDDY_API_KEY_HELPER_DISABLED 置空后,main 上该用例立即通过。

Validation

Windows + Python 3.14.7,.venv 内实跑:

命令 main (1a7ad4a) 本分支 (472cbe7)
CODEBUDDY_API_KEY_HELPER_DISABLED=1 python -m pytest tests/unit -q --basetemp=<仓库外、名称含 1 的路径> 1 failed, 163 passed, 1 skipped 172 passed, 1 skipped
python -m pytest tests/unit/test_eval_telemetry.py -q 4 failed, 1 passed 8 passed

新增失败 0;passed 增加 9 个 = 修复 1 个 + 新增 8 个用例。

  • git diff --check:通过。
  • CI:本仓库 .github/workflows/unit-tests.yml 只执行 python -m pytest tests/unit -q,未改配置。
  • 提示:--basetemp 请放在仓库外;放在仓库内会命中 Eval 输出目录保护,产生与本改动无关的失败。

评审跟进

针对 review 的两条 [P3]:

  1. 短值与 JSON 的契约描述不一致 —— 采纳。上一版描述里"JSON 短密钥仍由 _SECRET_RE 拦截"是错的,已删除并改写为上面的显式契约;新增用例把短值在 dict / JSON / 无引号三种形式下的行为固定下来。
  2. 下划线复合标识符仍会被改写 —— 采纳并改代码。边界字符类由 [A-Za-z0-9] 扩展为含下划线,postgres_user / postgres_backup 不再被切断,同时保留独立密钥的替换。

两条均为文档/边界完善,不涉及运行时安全回归,未恢复短值的运行时脱敏。跟进改动见独立提交 472cbe7。

AI assistance

  • 工具:WorkBuddy。
  • 用途:定位根因(最小化对照实验、--basetemp 确定性复现)、实现修复与回归测试、按 review 修正契约与边界、跑验证并整理前后数字。
  • 人类责任声明:我已逐行阅读本变更并对其负责;取舍与验证结论以本地实跑结果为准。

…tion

telemetry._redact() treated any environment variable whose *name* merely
contained API_KEY/_TOKEN/_SECRET/_PASSWORD/_DSN as a secret and then ran a
blind str.replace() of its whole value over every payload string.

A non-secret config flag is enough to trip this: with
X_API_KEY_HELPER_DISABLED=1 in the environment, every "1" inside a payload
was rewritten to "[REDACTED]", so an eval event recorded
".../pytest-201/projects/target-repo" as ".../pytest-20[REDACTED]/...".
Because the substitution ran before the real credentials were matched, a
genuine DSN containing "1" was only partially masked as well.

Only values long enough to be credential material are considered now, and
they are replaced in a single pass on token boundaries, so unrelated content
and longer identifiers are left intact while real secrets stay masked.
@Guo-Yixin

Copy link
Copy Markdown
Owner Author

审查提交:6cf7ff175f6d65e55c9687beb1bc431cf8e74647,对照 base 1a7ad4a00360b33637a93cd066d04d2b84ca2123。按 codex_pr 技能检查最终 diff、调用链、现有测试与补充边界用例。

结论:现有测试通过,没有阻断合并的问题;下面两点建议作为文档/边界完善处理。

1. [P3] 短值与 JSON 的契约描述需要一致

位置:telemetry.py:26,以及 PR 描述中“JSON 里的短密钥仍由 _SECRET_RE 拦截”的说明。

用完全隔离的环境变量和虚构密码实跑:

with patch.dict(os.environ, {"REVIEW_PASSWORD": "s3cr3t"}, clear=True):
    telemetry._redact({"password": "s3cr3t"})
    telemetry._redact('{"password": "s3cr3t"}')

两个结果都保留 s3cr3t;base 会替换成 [REDACTED]。record_eval_event("tool_result", {"content": '{"password": "s3cr3t"}'}) 也会把这个短值写进 JSONL。

原因是字典递归只检查 value,不结合 key;JSON 字符串里的 password 后面有一个引号,现有 _SECRET_RE 不能直接跨过它匹配冒号。控制组 password=s3cr3t 则仍然会被脱敏。

补充对照测试为 3 failed、5 passed:base 的 4 项全部通过,head 的结构化值、JSON 字符串与落盘测试失败,普通具名文本通过。

这里建议调整文档和测试契约即可:既然本次最低长度策略明确为 8,小于 8 的值就应归为普通配置值,而不是有效密钥,因此这些诊断失败不构成安全回归,无需为它们恢复运行时脱敏。建议删除“JSON 短密钥仍受保护”的保证,并新增测试明确短 JSON 值保持原样,真正的密钥用至少 8 字符的夹具验证。

2. [P3] 当前边界允许改写带下划线的复合标识符

位置:telemetry.py:39。

with patch.dict(os.environ, {"POSTGRES_PASSWORD": "postgres"}, clear=True):
    telemetry._redact({"repo_path": "/work/postgres_backup", "id": "postgres_user"})
# {"repo_path": "/work/[REDACTED]_backup", "id": "[REDACTED]_user"}

[A-Za-z0-9] 不包含 _,所以复合标识符内部仍会被替换。这个行为在 base 已存在,属于本次“完整 token 边界”保护尚未覆盖的范围,不是新增回归。建议补一组下划线标识符用例,再明确边界是否应包含 _。

实际验证

  • 干净隔离 worktree,Windows + Python 3.14.5,head 与 CI SHA 一致。
  • CODEBUDDY_API_KEY_HELPER_DISABLED=1 下执行全量 tests/unit,使用仓库外、名称含 1 的固定 basetemp:169 passed、1 skipped。
  • tests/unit/test_eval_telemetry.py + tests/unit/test_hybrid_search.py:9 passed。
  • GitHub Actions run 36844529930:169 passed、1 skipped。
  • git diff --check:通过。
  • skipped 为 PostgreSQL connection smoke,未配置 POSTGRES_DSN。

第一次全量复跑把 basetemp 放在仓库内,触发了 6 个 Eval 输出目录保护失败;改为仓库外后全部消失,因此没有归因于此 PR。审查未使用真实凭据,未修改或推送生产代码。

Address the two [P3] items from review.

1) Compound identifiers were still split. _redact_environment_secrets() used
   [A-Za-z0-9] as its token boundary, so a secret value still matched inside
   identifiers: with POSTGRES_PASSWORD=postgres, "/work/postgres_backup" was
   recorded as "/work/[REDACTED]_backup" and "postgres_user" as
   "[REDACTED]_user". Underscore is an identifier character and now belongs to
   the boundary; hyphen stays a separator because it delimits paths and CLI
   arguments. A standalone secret is still replaced.

2) The short-value contract is now explicit instead of documented wrong.
   Values shorter than _ENV_SECRET_MIN_LENGTH are plain configuration and are
   never substituted, in any form. That includes JSON: {"password": "s3cr3t"}
   keeps the value, because _SECRET_RE requires '=' or ':' directly after the
   marker name and the quote blocks the match. Only unquoted named forms such
   as password=s3cr3t are still masked. Tests cover both directions, plus a
   nested-payload case pinning that threshold-length secrets stay masked.
@Guo-Yixin

Copy link
Copy Markdown
Owner Author

两条 [P3] 都采纳,跟进改动见 472cbe7(独立提交,便于只看增量)。

1. 短值与 JSON 的契约描述需要一致 —— 采纳(改文档 + 补测试,不动运行时)

先复现确认了你的结论,同时确认了控制组:

# REVIEW_PASSWORD=s3cr3t(7 字符,低于阈值 8)
_redact({"password": "s3cr3t"})      # → {"password": "s3cr3t"}      保持原样
_redact('{"password": "s3cr3t"}')    # → '{"password": "s3cr3t"}'    保持原样
_redact("password=s3cr3t")           # → "password=[REDACTED]"       被 _SECRET_RE 拦截

你判断的原因也对:字典递归只看 value 不看 key;JSON 里 password 与 : 之间夹了引号,_SECRET_RE 匹配不到。

按你的建议处理:删除了 PR 描述里"JSON 短密钥仍由 _SECRET_RE 拦截"的表述,改成显式契约——短于阈值就是普通配置值,任何形式都不替换;_SECRET_RE 只覆盖无引号具名形式。新增 test_values_shorter_than_the_threshold_are_plain_configuration 把三种形式固定下来,并用 test_long_environment_secret_is_redacted_inside_nested_payloads 做对照,证明达到阈值的密钥在嵌套 payload 里仍被脱敏。未恢复短值的运行时脱敏。

2. 下划线复合标识符 —— 采纳并改代码

你的例子完全复现。这确实是我上一版留下的缺口:既然这版的目标是"不改写更长 token 的内部片段",那 _ 就不能留在边界外——它是标识符字符。

# before: POSTGRES_PASSWORD=postgres
{"repo_path": "/work/[REDACTED]_backup", "id": "[REDACTED]_user"}

# after
{"repo_path": "/work/postgres_backup", "id": "postgres_user"}

边界字符类由 [A-Za-z0-9] 扩为 [A-Za-z0-9_],抽成 _IDENTIFIER_CHAR 常量并写明取舍:下划线算标识符字符,连字符不算——连字符在路径和命令行里是分隔符,仍是合法边界。独立出现的密钥不受影响:password is postgres → password is [REDACTED]。新增 test_compound_identifiers_are_not_split_by_a_secret_value。

验证

  • CODEBUDDY_API_KEY_HELPER_DISABLED=1 + 仓库外、名称含 1 的固定 basetemp,全量 tests/unit:172 passed, 1 skipped(上版 169 passed,+3 为新增用例)。
  • tests/unit/test_eval_telemetry.py:8 passed(上版 5)。
  • git diff --check:通过。

另外采纳你关于 basetemp 的经验,已在 PR 描述里加了一条提示:--basetemp 要放在仓库外,否则会命中 Eval 输出目录保护,产生与本改动无关的失败。

@Guo-Yixin
Guo-Yixin merged commit b8550fa into main Oct 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant