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:
1 parent
06c3e172fe
commit
c06ba50983
13 files changed
+1922
-11
No files matched your search
@@ -0,0 +1,247 @@
|
||||
"""受限推理通道(feat-chv-muse-p9,叶子模块)。
|
||||
|
||||
对齐 Muse 官方安全文(security.muse.ai 2026-09-08,见
|
||||
docs/chv-muse-p9-gap.md):推理与遥测代理是通向外部基础设施的
|
||||
受限通道;Sentinel 是连接器动作与全部网络出口的唯一权限机构。
|
||||
|
||||
本模块是**决策层**,不是网络层强制:
|
||||
- 它不拦截客内流量。客内 codex 直连的强制仍靠 Tinyproxy 白名单 +
|
||||
宿主防火墙(第三棒落地中);不要把本模块写成“出口已全部经
|
||||
Sentinel”——与 P3 的 sentinel 模块同款声明。
|
||||
- 唯一出口点:模型调用只允许走这里决定的 origin。执行器在
|
||||
`_run_with_vm` 起 VM 前调 `check_inference_call`:allow 才继续;
|
||||
deny/ask 一律审计 + 停止,不起 VM。
|
||||
- provider=`sub2api` 时 origin 取 `SUB2API_BASE_URL`;provider 为空
|
||||
(xcmapi 路径,codex 用自身 config.toml 默认网关)时执行器看不
|
||||
到 origin,改取 operator 显式配置的 `CHV_INFERENCE_ORIGIN`,未配
|
||||
即 ask(fail-closed,不静默放行)。部署迁移见差距表 §2.5。
|
||||
- 能力说明:allow 时带出的严格能力(单次、300 秒)是**起 VM 前的
|
||||
预审授权**,不是逐次 HTTP 请求的令牌;客内 codex 的逐次调用仍由
|
||||
代理白名单 + 防火墙约束。本模块不伪装成逐请求强制。
|
||||
|
||||
本模块只用标准库,不依赖 cloud.*(除 vendored 的 sentinel),
|
||||
解析器可注入(`resolve` 参数),默认 `socket.getaddrinfo`;测试
|
||||
用假解析器,绝不联网。审计记录里绝不含凭证值。
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import NamedTuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from cloud.chv.sentinel import (
|
||||
Decision,
|
||||
EgressRequest,
|
||||
SentinelPolicy,
|
||||
_one_shot_capability,
|
||||
)
|
||||
|
||||
INFERENCE_CONNECTOR = "inference"
|
||||
|
||||
# 推理连接器的 L7 路径规则:codex 以 wire_api="responses" 调模型,
|
||||
# 只放 POST /v1/responses;GET /v1/models 是否必需待真机核实
|
||||
# (codex 启动时是否拉模型列表),若非必需则收紧为只放前者。
|
||||
INFERENCE_PATH_RULES = (
|
||||
("POST", "prefix", "/v1/responses"),
|
||||
("GET", "prefix", "/v1/models"),
|
||||
)
|
||||
|
||||
# 上限默认值(本项目自定,非 Meta 官方值):超时 120 秒与
|
||||
# host.py 的 Server.request 默认超时一致;重试 1 次即保持
|
||||
# 现有行为不变(现为 range(2))。非法值一律 ValueError。
|
||||
DEFAULT_INFERENCE_TIMEOUT_SECONDS = 120
|
||||
DEFAULT_INFERENCE_MAX_RETRIES = 1
|
||||
MIN_INFERENCE_TIMEOUT_SECONDS = 10
|
||||
MAX_INFERENCE_TIMEOUT_SECONDS = 600
|
||||
MAX_INFERENCE_RETRIES = 5
|
||||
|
||||
|
||||
class InferenceLimits(NamedTuple):
|
||||
"""推理通道上限:单次请求超时(秒)与重连重试上限(次)。"""
|
||||
|
||||
timeout_seconds: int
|
||||
max_retries: int
|
||||
|
||||
|
||||
class InferenceCheck(NamedTuple):
|
||||
"""一次预审的结果:决策 + 被评估的请求(origin 缺失/非法时
|
||||
request 为 None,审计方用原因兜底)。"""
|
||||
|
||||
decision: Decision
|
||||
request: EgressRequest | None
|
||||
|
||||
|
||||
def _env_of(env: dict | None) -> dict:
|
||||
"""测试传 dict,生产默认读 os.environ。"""
|
||||
return env if env is not None else os.environ # type: ignore[return-value]
|
||||
|
||||
|
||||
def _is_local_relay_host(host: str) -> bool:
|
||||
"""字面环回/私网 IP 才是本地中继主机(无 DNS,无重绑定;
|
||||
主机名一律不是——DNS 解析结果不可信)。"""
|
||||
try:
|
||||
addr = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return False
|
||||
return addr.is_loopback or addr.is_private
|
||||
|
||||
|
||||
def resolve_inference_origin(provider: str | None,
|
||||
env: dict | None = None) -> str:
|
||||
"""解析推理出口 origin(不带尾斜杠,端口非默认时保留)。
|
||||
|
||||
- provider="sub2api":取 `SUB2API_BASE_URL`;
|
||||
- provider 为空或未知标签:取 `CHV_INFERENCE_ORIGIN`(与
|
||||
host.py `_server_command` 一致走默认网关;标签本身不决定
|
||||
origin,origin 永远只取 operator 配置的 env);
|
||||
变量未配/空白返回 ""(调用方按 ask 处理,不在这里抛错)。
|
||||
scheme 规则(见 cloud/README.md 的 sub2api 网关一节):
|
||||
- https:公网网关(如 xgapi.lol),端口只接受 443(或缺省);
|
||||
- http:只接受本地中继——字面环回/私网 IP 主机(如文档形状
|
||||
http://172.17.0.1:28084,Docker 桥接 IP),端口保留;
|
||||
- 其余(http 公网主机名、https 非 443 端口、带 userinfo、
|
||||
无 host)一律 ValueError——配置错了要 loud,不要静默跑向别处。
|
||||
报错不带 origin 原值(http URL 里可能含 userinfo)。
|
||||
"""
|
||||
values = _env_of(env)
|
||||
if provider is None:
|
||||
raw = values.get("CHV_INFERENCE_ORIGIN") or ""
|
||||
elif provider == "sub2api":
|
||||
raw = values.get("SUB2API_BASE_URL") or ""
|
||||
else:
|
||||
# 未知 provider 标签:与 host.py `_server_command` 一致走
|
||||
# 默认网关(`CHV_INFERENCE_ORIGIN`)——标签本身不决定
|
||||
# origin,origin 永远只取 operator 配置的 env。
|
||||
raw = values.get("CHV_INFERENCE_ORIGIN") or ""
|
||||
raw = raw.strip().rstrip("/")
|
||||
if not raw:
|
||||
return ""
|
||||
parsed = urlparse(raw)
|
||||
scheme = parsed.scheme.lower()
|
||||
if "@" in parsed.netloc:
|
||||
raise ValueError("推理 origin 不得带 userinfo(凭证禁进 URL)")
|
||||
host = (parsed.hostname or "").lower()
|
||||
if not host:
|
||||
raise ValueError("推理 origin 无主机名")
|
||||
if scheme == "https":
|
||||
if parsed.port is not None and parsed.port != 443:
|
||||
raise ValueError("推理 origin 端口只接受 443")
|
||||
return "https://" + host
|
||||
if scheme == "http":
|
||||
if not _is_local_relay_host(host):
|
||||
raise ValueError("http 推理 origin 只接受本地中继"
|
||||
"(环回/私网 IP)")
|
||||
port = parsed.port
|
||||
if port is not None:
|
||||
return "http://%s:%d" % (host, port)
|
||||
return "http://" + host
|
||||
raise ValueError("推理 origin 必须 https(或本地中继 http)")
|
||||
|
||||
|
||||
def inference_allow_hosts(provider: str | None,
|
||||
env: dict | None = None) -> frozenset:
|
||||
"""推理出口 allowlist(小写主机名集合)。
|
||||
|
||||
`CHV_INFERENCE_ALLOW_HOSTS`(逗号分隔)显式覆盖;未配时默认
|
||||
即 origin 自身的主机——allowlist 永远是“显式配置的 origin”,
|
||||
不是“解析后是什么都行”。origin 缺失时返回空集(预审必 ask)。
|
||||
"""
|
||||
values = _env_of(env)
|
||||
raw = values.get("CHV_INFERENCE_ALLOW_HOSTS") or ""
|
||||
hosts = frozenset(h.strip().lower()
|
||||
for h in raw.split(",") if h.strip())
|
||||
if hosts:
|
||||
return hosts
|
||||
try:
|
||||
origin = resolve_inference_origin(provider, env)
|
||||
except ValueError:
|
||||
return frozenset()
|
||||
if not origin:
|
||||
return frozenset()
|
||||
host = urlparse(origin).hostname or ""
|
||||
return frozenset({host}) if host else frozenset()
|
||||
|
||||
|
||||
def _parse_int_env(env: dict, name: str, default: int,
|
||||
low: int, high: int) -> int:
|
||||
"""严格整数 env 解析:未配即默认;非整数/越界即 ValueError,
|
||||
绝不静默回落(配错早炸,不留到起 VM 才暴露)。"""
|
||||
raw = env.get(name)
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return default
|
||||
try:
|
||||
value = int(str(raw).strip())
|
||||
except ValueError:
|
||||
raise ValueError("%s 必须是整数: %r" % (name, raw))
|
||||
if not low <= value <= high:
|
||||
raise ValueError("%s 必须在 %d-%d 之间: %s"
|
||||
% (name, low, high, value))
|
||||
return value
|
||||
|
||||
|
||||
def inference_limits(env: dict | None = None) -> InferenceLimits:
|
||||
"""推理通道上限(见模块头默认值说明)。"""
|
||||
values = _env_of(env)
|
||||
return InferenceLimits(
|
||||
timeout_seconds=_parse_int_env(
|
||||
values, "CHV_INFERENCE_TIMEOUT",
|
||||
DEFAULT_INFERENCE_TIMEOUT_SECONDS,
|
||||
MIN_INFERENCE_TIMEOUT_SECONDS,
|
||||
MAX_INFERENCE_TIMEOUT_SECONDS),
|
||||
max_retries=_parse_int_env(
|
||||
values, "CHV_INFERENCE_MAX_RETRIES",
|
||||
DEFAULT_INFERENCE_MAX_RETRIES, 0, MAX_INFERENCE_RETRIES),
|
||||
)
|
||||
|
||||
|
||||
def check_inference_call(provider: str | None,
|
||||
env: dict | None = None,
|
||||
resolve: Callable[[str, int], list] | None = None,
|
||||
policy: SentinelPolicy | None = None,
|
||||
now: float | None = None) -> InferenceCheck:
|
||||
"""预审一次推理出口调用(起 VM 前,fail-closed)。
|
||||
|
||||
- origin 缺失 → ask(`inference_origin_not_configured`),
|
||||
调用方停止执行,不得自动放行;
|
||||
- origin 非法 → deny;
|
||||
- http 本地中继(字面环回/私网 IP):无 DNS、无重绑定,跳过
|
||||
Sentinel 的 DNS/SSRF 步;allowlist 与单次能力照常执行;
|
||||
- 其余走 Sentinel 评估:SSRF、私网 IP、scheme/端口/路径越界
|
||||
即 deny;主机不在白名单即 ask;全过即 allow + 严格能力
|
||||
(单次、300 秒,见模块头的能力说明)。
|
||||
`resolve` / `policy` 可注入(假解析器 / P5 纵深策略),默认按 allowlist 现建。
|
||||
"""
|
||||
try:
|
||||
origin = resolve_inference_origin(provider, env)
|
||||
except ValueError:
|
||||
# 原因不带原值:非法 origin 的原串里可能含 userinfo,
|
||||
# 审计原因只记“非法”本身,不记值。
|
||||
return InferenceCheck(
|
||||
Decision("deny", ["inference_origin_invalid"]), None)
|
||||
if not origin:
|
||||
return InferenceCheck(
|
||||
Decision("ask", ["inference_origin_not_configured"]), None)
|
||||
parsed = urlparse(origin)
|
||||
scheme = parsed.scheme
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port or (443 if scheme == "https" else 80)
|
||||
if policy is None:
|
||||
policy = SentinelPolicy(
|
||||
allow_hosts=inference_allow_hosts(provider, env),
|
||||
connector_paths={INFERENCE_CONNECTOR: INFERENCE_PATH_RULES})
|
||||
request = EgressRequest(INFERENCE_CONNECTOR, "POST", scheme,
|
||||
host, port, "/v1/responses")
|
||||
if scheme == "http":
|
||||
# 本地中继分支:见上。allowlist 不含该主机 → ask;
|
||||
# 含 → allow + 与公网路径同款单次能力(P3 默认档)。
|
||||
if host not in policy.allow_hosts:
|
||||
return InferenceCheck(
|
||||
Decision("ask", ["host_not_allowlisted"]), request)
|
||||
rules = policy.connector_paths.get(INFERENCE_CONNECTOR, ())
|
||||
capability = _one_shot_capability(request, rules, now)
|
||||
return InferenceCheck(
|
||||
Decision("allow", ["policy_allow", "local_relay"],
|
||||
capability=capability), request)
|
||||
decision = policy.evaluate(request, resolve=resolve, now=now)
|
||||
return InferenceCheck(decision, request)
|
||||
Reference in new issue
Block a user