Files
codex-app-server/cloud/chv/inference_channel.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

248 lines
11 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):推理与遥测代理是通向外部基础设施的
受限通道;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)