Files
codex-app-server/cloud/chv/telemetry.py
T
host c06ba50983 feat-chv-muse-p9: 推理与遥测受限通道(Sentinel 预审 + 遥测最小化 + 训练退出开关)
- 受限推理通道:cloud/chv/inference_channel.py(origin 解析、
  allowlist、上限、Sentinel 预审),执行器 _run_with_vm 起 VM 前
  必过 _inference_gate(allow 才继续,deny/ask 审计 + fail-closed,
  任务级 extra_env 合并进预审);host.py 第二道防线(_server_command
  的 origin 结构校验 + Server.request 超时/重试走 env)。
  origin 规则:https 公网网关(只 443);http 只接受本地中继
  (字面环回/私网 IP,如 http://172.17.0.1:28084,端口保留,
  无 DNS、无重绑定,allowlist 与单次能力照常);未知 provider
  标签走默认网关(与 _server_command 一致)。
- sentinel.py:从 P5(25bcd35)取的超集版本 + 推理连接器条目
  (POST /v1/responses、GET /v1/models;scheme/端口只 https/443,
  本地中继不走 evaluate,不把 http 加进表)。
- 遥测最小化:cloud/chv/telemetry.py(字段白名单、确定性采样、
  落盘失败不影响任务、token 未回传记 null)+ 训练退出开关
  (默认开,本项目决定;关后轨迹不再产生;损坏 fail-closed;变更审计)。
- 访问边界:docs/chv-access-boundary.md(运营访问口径 + 审计钩子,
  不冒充技术不可访问;Confidential VM 明确未做)。
- 测试:cloud/tests/test_chv_muse_p9.py 51 组全过;5 个旧文件
  的 make_exec 配假 origin + 假解析器(新 fail-closed 门要求的
  测试配置);全量 cloud 套件 1918 OK(skipped=8)。
  详见 docs/chv-muse-p9-gap.md(含自定清单与真机待验证清单)。
2026-11-06 23:03:06 +00:00

212 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""遥测最小化 + 训练退出开关(feat-chv-muse-p9,叶子模块)。
对齐 Muse 官方安全文(security.muse.ai 2026-09-08,见
docs/chv-muse-p9-gap.md):VM 发送有限数据用于推理与遥测
(安全文未给字段级清单——本模块的字段清单按最小化原则自定,
见 `TELEMETRY_FIELDS`);推理轨迹在去除关键个人身份信息后可
用于训练新检查点,用户可一键退出训练。
诚实边界(见差距表 §4):
- 遥测目前只落盘 `<state_dir>/telemetry.jsonl`,没有向外部遥测
端点发送的路径;落盘失败也不影响任务(`emit_telemetry`
永不抛错)。
- 训练轨迹目前没有上传路径:开关管的是“轨迹类事件是否产生”;
上传通道本身不存在,开关是为上传通道预留的门禁。
- 字段白名单是构造性保证:不在白名单里的键一律丢弃;token 数
只取 host.py 回传的 usage,未回传记 `null`,不估算、不编造。
- 训练开关默认开是**本项目决定,非 Meta 官方值**(报告注明
帮助中心原文未逐字确认默认开启)。
本模块只用标准库 + `atomic_file`(开关状态文件原子写)。
"""
import hashlib
import json
import logging
import os
import time
from cloud.chv.atomic_file import atomic_write_text
_LOGGER = logging.getLogger(__name__)
# 遥测字段白名单(自定,按最小化原则):只含任务级元数据。
# 绝不含对话原文、文件内容、密钥、个人身份信息——白名单外
# 的键在 sanitize 时一律丢弃,不是“记下来再脱敏”。
TELEMETRY_FIELDS = frozenset({
"task_id", "vm_id", "provider", "model", "status",
"started_at", "ended_at", "duration_ms",
"tokens_in", "tokens_out", "tokens_total",
"turns", "tool_calls", "sampled",
})
# 轨迹类事件字段白名单(自定):只含回合级元数据,不含任何文本。
TRAJECTORY_FIELDS = frozenset({
"task_id", "turns", "tools", "duration_ms",
})
TRAINING_OPT_FILENAME = "training-opt.json"
TRAINING_OPT_AUDIT_FILENAME = "training-opt-audit.jsonl"
TELEMETRY_FILENAME = "telemetry.jsonl"
DEFAULT_TELEMETRY_SAMPLE_RATE = 1.0
def telemetry_sample_rate(env: dict | None = None) -> float:
"""遥测采样率:`CHV_TELEMETRY_SAMPLE_RATE`,0.0–1.0,默认 1.0
(全量,任务级元数据量小)。非法值即 ValueError。"""
values = env if env is not None else os.environ # type: ignore[assignment]
raw = values.get("CHV_TELEMETRY_SAMPLE_RATE")
if raw is None or str(raw).strip() == "":
return DEFAULT_TELEMETRY_SAMPLE_RATE
try:
rate = float(str(raw).strip())
except ValueError:
raise ValueError(
"CHV_TELEMETRY_SAMPLE_RATE 必须是数字: %r" % raw)
if not 0.0 <= rate <= 1.0:
raise ValueError(
"CHV_TELEMETRY_SAMPLE_RATE 必须在 0.0-1.0 之间: %s" % rate)
return rate
def sampled(task_id: str, rate: float) -> bool:
"""确定性采样:同一 task_id 每次判定一致(可测试);rate=0
全丢,rate=1 全留。"""
digest = hashlib.sha256(task_id.encode("utf-8")).hexdigest()
return int(digest[:8], 16) % 10000 < rate * 10000
def _is_scalar(value: object) -> bool:
"""遥测值只认标量(str/int/float/bool/None);dict/list
一律丢弃——不把结构化内容带进遥测。"""
return value is None or isinstance(value, (str, int, float, bool))
def sanitize_telemetry_event(event: dict) -> dict:
"""按白名单过滤遥测事件:不在白名单里的键丢弃;值不是标量
的丢弃;`tools` 字段例外——只认 str 列表(工具名),其它
元素一律去掉。返回新 dict,不改原 event。"""
clean: dict = {}
for key, value in (event or {}).items():
if key not in TELEMETRY_FIELDS and key not in TRAJECTORY_FIELDS:
continue
if key == "tools":
if isinstance(value, (list, tuple)):
clean[key] = [t for t in value if isinstance(t, str)]
continue
if _is_scalar(value):
clean[key] = value
return clean
def build_task_telemetry(task_id: str, vm_id: str, provider: str,
model: str | None, status: str,
started_at: float, ended_at: float,
duration_ms: int,
tokens: dict | None = None,
turns: int | None = None,
tool_calls: int | None = None,
env: dict | None = None) -> dict | None:
"""建一条任务级遥测事件;采样未命中返回 None(不产生事件,
不是产生后丢弃)。tokens 取 host.py 回传的 usage,未回传
时三个 token 字段记 None,不编造。"""
rate = telemetry_sample_rate(env)
if not sampled(task_id, rate):
return None
tokens = tokens or {}
event = {
"task_id": task_id, "vm_id": vm_id, "provider": provider,
"model": model, "status": status,
"started_at": started_at, "ended_at": ended_at,
"duration_ms": duration_ms,
"tokens_in": tokens.get("input_tokens"),
"tokens_out": tokens.get("output_tokens"),
"tokens_total": tokens.get("total_tokens"),
"turns": turns, "tool_calls": tool_calls, "sampled": True,
}
return sanitize_telemetry_event(event)
def build_trajectory_event(task_id: str, turns: int,
tools: list, duration_ms: int,
state_dir: str) -> dict | None:
"""建一条轨迹类事件(训练用):只含回合级元数据(回合数、
工具名、耗时),不含任何文本。训练退出开关关时返回 None
(事件不再产生)。"""
if not training_enabled(state_dir):
return None
return sanitize_telemetry_event({
"task_id": task_id, "turns": turns,
"tools": list(tools or []), "duration_ms": duration_ms,
})
def _append_jsonl(path: str, record: dict) -> bool:
"""best-effort 追加一行 JSONL:失败返回 False,永不抛错
(遥测/审计落盘失败绝不影响任务)。"""
try:
line = json.dumps(record, ensure_ascii=False) + "\n"
with open(path, "a", encoding="utf-8") as fh:
fh.write(line)
fh.flush()
return True
except Exception as exc: # noqa: BLE001 - 落盘失败只记日志
_LOGGER.warning("telemetry_append_failed path=%s error=%s",
path, exc)
return False
def emit_telemetry(state_dir: str, event: dict) -> bool:
"""发一条遥测事件:先过白名单再落盘 `<state_dir>/telemetry.jsonl`。
落盘失败返回 False,永不抛错、不影响任务。"""
if not state_dir or not isinstance(event, dict):
return False
clean = sanitize_telemetry_event(event)
if not clean:
return False
return _append_jsonl(
os.path.join(state_dir, TELEMETRY_FILENAME), clean)
def training_enabled(state_dir: str) -> bool:
"""训练退出开关状态:默认开(本项目决定,非 Meta 官方值)。
状态文件缺失即默认开;损坏/不可解析/缺 `enabled` 键时
fail-closed 当作关处理并记日志(损坏不等于同意上传)。"""
path = os.path.join(state_dir, TRAINING_OPT_FILENAME)
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except FileNotFoundError:
return True
except Exception as exc: # noqa: BLE001 - 损坏即关
_LOGGER.warning("training_opt_corrupt path=%s error=%s",
path, exc)
return False
if not isinstance(data, dict) or "enabled" not in data:
return False
return bool(data["enabled"])
def set_training_enabled(state_dir: str, enabled: bool,
actor: str = "user") -> bool:
"""一键退出/恢复训练:状态原子写 `<state_dir>/training-opt.json`
(0600),变更追加进 `training-opt-audit.jsonl`。`enabled`
不是 bool 即 TypeError;写失败返回 False,永不抛错。
关后立即生效:后续轨迹事件不再产生。"""
if not isinstance(enabled, bool):
raise TypeError("enabled 必须是 bool: %r" % (enabled,))
record = {"enabled": enabled, "updated_at": time.time(),
"actor": str(actor)[:64]}
try:
atomic_write_text(
os.path.join(state_dir, TRAINING_OPT_FILENAME),
json.dumps(record, ensure_ascii=False))
except Exception as exc: # noqa: BLE001 - 写失败只记日志
_LOGGER.warning("training_opt_write_failed error=%s", exc)
return False
audit = dict(record)
audit["event"] = "training_opt_changed"
_append_jsonl(
os.path.join(state_dir, TRAINING_OPT_AUDIT_FILENAME), audit)
return True