"""受限推理通道(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)