- 受限推理通道: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(含自定清单与真机待验证清单)。
212 lines
8.7 KiB
Python
212 lines
8.7 KiB
Python
"""遥测最小化 + 训练退出开关(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
|