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(含自定清单与真机待验证清单)。
This commit is contained in:
host committed 2026-11-06 23:03:06 +00:00
1 parent 06c3e172fe
commit c06ba50983
13 files changed
+1922 -11

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
"""遥测最小化 + 训练退出开关(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