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
@@ -2245,6 +2245,54 @@ class CloudHypervisorExecutor(Executor):
|
||||
"interrupted_reason": reason,
|
||||
}
|
||||
|
||||
def _inference_gate(self, task: dict,
|
||||
model_provider: str | None,
|
||||
extra_env: dict | None = None) -> None:
|
||||
"""feat-chv-muse-p9:起 VM 前的推理出口预审。
|
||||
|
||||
模型调用只允许走受限推理通道(见 inference_channel):
|
||||
allow → 审计放行;deny/ask → 审计 + ChvFailure 起 VMM
|
||||
前 fail-closed(ask 在这里就是 deny:wake 等不了审批
|
||||
UI)。解析器可注入(`sentinel_resolver` 属性,测试用
|
||||
假解析器)。origin 取任务级 extra_env 覆盖执行器 env;
|
||||
origin 非法同样记 deny,不抛裸 ValueError。审计 detail
|
||||
只含 VM 名/主机/verdict,不含任何凭证值。
|
||||
"""
|
||||
from cloud.chv.inference_channel import check_inference_call
|
||||
name = self.vm_name(task)
|
||||
env = dict(self._env)
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
try:
|
||||
check = check_inference_call(
|
||||
model_provider, env=env,
|
||||
resolve=getattr(self, "sentinel_resolver", None))
|
||||
except ValueError as exc:
|
||||
self._emit("inference_egress_deny", task,
|
||||
"vm=%s reasons=bad_origin" % name)
|
||||
raise ChvFailure(
|
||||
"inference_egress_denied",
|
||||
"推理 origin 配置非法,拒绝启动:%s" % exc)
|
||||
decision = check.decision
|
||||
if decision.verdict == "allow":
|
||||
capability = decision.capability
|
||||
request = check.request
|
||||
assert capability is not None # allow 恒带严格能力
|
||||
assert request is not None # allow 恒带已解析请求
|
||||
self._emit("inference_egress_allow", task,
|
||||
"vm=%s host=%s capability=%s"
|
||||
% (name, request.host, capability.id))
|
||||
return
|
||||
host = check.request.host if check.request is not None else ""
|
||||
self._emit("inference_egress_" + decision.verdict, task,
|
||||
"vm=%s host=%s reasons=%s"
|
||||
% (name, host, ",".join(decision.reasons)))
|
||||
raise ChvFailure(
|
||||
"inference_egress_denied",
|
||||
"推理出口预审未通过(%s),拒绝启动:请运维确认 "
|
||||
"provider 与 CHV_INFERENCE_ORIGIN/CHV_INFERENCE_ALLOW_HOSTS"
|
||||
% decision.verdict)
|
||||
|
||||
def _run_with_vm(self, task, inner_args, goal=False, extra_env=None,
|
||||
**kwargs):
|
||||
"""每任务 VM 生命周期(冷启动正式路径,feat-chv-arch95
|
||||
@@ -2266,6 +2314,8 @@ class CloudHypervisorExecutor(Executor):
|
||||
feat-chv-secfix(H2):extra_env 透传给 wake_vm(密钥
|
||||
子集在 provisioned 模式下随 seed 投递),再透传给
|
||||
_run_guest(远程命令 source 客内密钥文件)。"""
|
||||
self._inference_gate(task, kwargs.get("model_provider"),
|
||||
extra_env=extra_env)
|
||||
self.wake_vm(task, extra_env=extra_env)
|
||||
try:
|
||||
try:
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,486 @@
|
||||
"""Sentinel 式出口审批决策引擎(feat-chv-muse-p3,叶子模块)。
|
||||
|
||||
对齐 Muse 官方安全文(security.muse.ai 2026-09-08,见
|
||||
docs/chv-muse-p3-gap.md):Sentinel 是连接器动作与全部网络出口的
|
||||
唯一权限机构;L4 与 L7 评估(主机名、解析后与最终目的 IP、端口、
|
||||
协议、HTTP 方法、路径、解码后的实际请求);SSRF 防护(防止公开
|
||||
主机名在 DNS 解析后指向私有基础设施);审批是严格能力
|
||||
(strict capabilities),绑定到特定连接器/目的地与用例,支持
|
||||
一次性/会话级/任务级/时限级授权;审批 UI 不在对话内。
|
||||
|
||||
本模块只是决策层(纯函数 + 内存态队列),不是网络层强制:
|
||||
- 它不拦截客内流量。客内直连的强制仍靠 Tinyproxy 白名单 +
|
||||
宿主防火墙(第三棒落地中);eBPF cgroup 拦截 + LSM hook 污点
|
||||
传播是后续工作,见差距表 §3.4,不得把本模块写成“出口已全部
|
||||
经 Sentinel”。
|
||||
- 本期两个接线点:① 执行器 wake 时的 Gitea 出口预审(起 VMM 前
|
||||
fail-closed);② 客内 authd 放凭证前评估。两者都调本模块的
|
||||
`evaluate()`,决策记录进审计(无值)。
|
||||
- `ask` 的语义:主机不在白名单、也不在拒绝名单时返回 ask,并记入
|
||||
`ApprovalQueue` 待审批;调用方遇到 ask 必须停止执行、不得自动
|
||||
放行。审批 UI 不在对话内——本期只留队列,不实现任何对话内审批
|
||||
入口(见差距表 §1)。
|
||||
|
||||
本模块只用标准库,不依赖 cloud.*,客内 authd 守护进程可直接
|
||||
import 它(见 chv-guest/authd.py)。解析器可注入(`resolve`
|
||||
参数),默认 `socket.getaddrinfo`;测试用假解析器,绝不联网。
|
||||
审计记录里绝不含凭证值、也绝不含解析出的 IP 以外的内容——
|
||||
reason 只含网段名与策略名。
|
||||
|
||||
P5 纵深(feat-chv-muse-p5):evaluate() 可接三件套——污点追踪器
|
||||
(taint.py:污点进程失去自动放行、回到正常审批流)、默认策略
|
||||
(policy.py:干净进程 + 只读 + 已允许 + 低风险可自动放行,
|
||||
三档谨慎度)、五档严格能力(capabilities.py:一次/会话/任务/
|
||||
时限/永久,默认仍单次);审批答复只走 approval_channel.py 的
|
||||
独立通道(nonce + 过期 + HMAC),对话内容伪造无效;eBPF 程序
|
||||
源码与加载器在 ebpf_taint.py(本机实测加载被 seccomp 挡,见
|
||||
docs/chv-muse-p5-gap.md §3)。顶层仍只用标准库(客内 authd
|
||||
直接 import 不受影响);五档能力签发是函数内懒 import,只在
|
||||
tier != "one-shot" 时触发。
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class _TaintTracker(Protocol):
|
||||
"""污点追踪器口径(P5-A 的 TaintTracker 须满足):只读
|
||||
一个方法,不持有追踪器实例、不 import taint 模块——
|
||||
客内 authd 直接 import 本模块时不受影响。"""
|
||||
|
||||
def is_tainted(self, pid: int) -> bool: ...
|
||||
|
||||
|
||||
class _DefaultPolicy(Protocol):
|
||||
"""默认策略口径(P5-B 的 DefaultPolicy 须满足)。"""
|
||||
|
||||
def auto_allow_eligible(self, request: "EgressRequest",
|
||||
tainted: bool) -> bool: ...
|
||||
|
||||
# SSRF 挡截:解析到这些网段的 IP 一律 deny。公开主机名解析到
|
||||
# 私有地址即挡(Muse 安全文的 SSRF 防护口径);解析失败也
|
||||
# deny(fail-closed,不把“解析不出”当成“可以放行”)。
|
||||
_BLOCKED_NETWORKS = tuple(
|
||||
ipaddress.ip_network(net) for net in (
|
||||
"0.0.0.0/8", # 本网络 / 未指定
|
||||
"10.0.0.0/8", # RFC1918
|
||||
"100.64.0.0/10", # CGNAT 共享地址空间
|
||||
"127.0.0.0/8", # 回环
|
||||
"169.254.0.0/16", # 链路本地
|
||||
"172.16.0.0/12", # RFC1918
|
||||
"192.0.0.0/24", # IETF 协议分配
|
||||
"192.0.2.0/24", # 文档 TEST-NET-1
|
||||
"192.88.99.0/24", # 6to4 中继(已废弃)
|
||||
"192.168.0.0/16", # RFC1918
|
||||
# 注意:198.18.0.0/15(基准测试)故意不在表里——本部署的
|
||||
# egress/DNS 基础设施自用该网段,gitea.tose.top 在此环境
|
||||
# 真实解析到 198.18.139.152;挡掉它会把真 Gitea 主机也拒了。
|
||||
"198.51.100.0/24", # 文档 TEST-NET-2
|
||||
"203.0.113.0/24", # 文档 TEST-NET-3
|
||||
"224.0.0.0/4", # 组播
|
||||
"240.0.0.0/4", # 保留
|
||||
"255.255.255.255/32",
|
||||
"::1/128", # 回环
|
||||
"::/128", # 未指定
|
||||
"fc00::/7", # 唯一本地
|
||||
"fe80::/10", # 链路本地
|
||||
"ff00::/8", # 组播
|
||||
))
|
||||
|
||||
# 每连接器的 L7 路径策略:(方法, 匹配方式, 模式)。
|
||||
# gitea:API 只放 /api/v1/;git 智能 HTTP 只放三条(clone/pull/push
|
||||
# 的实际路径,见 git http-protocol)。其它路径(Web UI、登录页、
|
||||
# 任意管理接口)一律不在本连接器的能力内。
|
||||
# feat-chv-muse-p9 的 inference 条目:推理连接器只放
|
||||
# POST /v1/responses 与 GET /v1/models(/v1/models 是否必需待
|
||||
# 真机核实);scheme/端口见下面两表。本文件其余内容与 P5 的
|
||||
# 25bcd35 一致,合入时以本文件为准(它是 P3+P5+P9 的并集)。
|
||||
_CONNECTOR_PATH_RULES = {
|
||||
"gitea": (
|
||||
("GET", "prefix", "/api/v1/"),
|
||||
("POST", "prefix", "/api/v1/"),
|
||||
("GET", "suffix", ".git/info/refs"),
|
||||
("POST", "suffix", ".git/git-upload-pack"),
|
||||
("POST", "suffix", ".git/git-receive-pack"),
|
||||
),
|
||||
"inference": (
|
||||
("POST", "prefix", "/v1/responses"),
|
||||
("GET", "prefix", "/v1/models"),
|
||||
),
|
||||
}
|
||||
|
||||
# 每连接器允许的 scheme/端口(默认:只 https/443;http 只在
|
||||
# 显式放行时才出现,本期没有任何连接器放 http)。注意:推理的
|
||||
# http 本地中继(字面环回/私网 IP,如 http://172.17.0.1:28084)
|
||||
# 不走 evaluate(见 inference_channel 的本地中继分支),不要
|
||||
# “顺手”把 http 加进下面两表——那会放宽公网路径的 scheme 检查。
|
||||
_CONNECTOR_SCHEMES = {"gitea": frozenset({"https"}),
|
||||
"inference": frozenset({"https"})}
|
||||
_CONNECTOR_PORTS = {"gitea": frozenset({443}),
|
||||
"inference": frozenset({443})}
|
||||
|
||||
# 默认放凭证有效期(秒):与 authd 按次释放同款 300 秒。
|
||||
DEFAULT_CAPABILITY_TTL_SECONDS = 300
|
||||
|
||||
|
||||
def _blocked_network_for(ip_text: str):
|
||||
"""该 IP 落在哪个挡截网段里(无即 None)。非法 IP 文本
|
||||
按“挡不住也说不清”处理:返回 0.0.0.0/8(deny),绝不
|
||||
把解析异常当成放行信号。"""
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip_text)
|
||||
except ValueError:
|
||||
return _BLOCKED_NETWORKS[0]
|
||||
for net in _BLOCKED_NETWORKS:
|
||||
if addr.version == net.version and addr in net:
|
||||
return net
|
||||
return None
|
||||
|
||||
|
||||
def _default_resolve(host: str, port: int) -> list:
|
||||
"""默认解析器:socket.getaddrinfo 去重后的 IP 列表。"""
|
||||
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
||||
out = []
|
||||
for info in infos:
|
||||
ip = info[4][0]
|
||||
if ip not in out:
|
||||
out.append(ip)
|
||||
return out
|
||||
|
||||
|
||||
class EgressRequest:
|
||||
"""一次出口请求(决策输入):连接器 + 方法 + 目标 + 解析后
|
||||
IP(解析在 evaluate 里做,这里只记目标描述,不记任何凭证值)。"""
|
||||
|
||||
def __init__(self, connector: str, method: str, scheme: str,
|
||||
host: str, port: int, path: str) -> None:
|
||||
self.connector = connector
|
||||
self.method = (method or "").upper()
|
||||
self.scheme = (scheme or "").lower()
|
||||
self.host = (host or "").lower()
|
||||
self.port = int(port)
|
||||
self.path = path or "/"
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""审计用描述(只含连接器/目标/方法,不含值)。"""
|
||||
return {"connector": self.connector, "method": self.method,
|
||||
"scheme": self.scheme, "host": self.host,
|
||||
"port": self.port, "path": self.path}
|
||||
|
||||
|
||||
class Capability:
|
||||
"""严格能力(Muse 口径:绑定到特定连接器/目的地与用例)。
|
||||
|
||||
一次 evaluate().capability 只对:一个连接器、一个主机、声明
|
||||
的方法与路径模式、在有效期内、剩余次数内有效。越界使用
|
||||
(换主机、换方法、换路径、过期、用完)一律 `matches()` 为
|
||||
False,调用方必须重新走 evaluate,不得自行续期。"""
|
||||
|
||||
def __init__(self, connector: str, host: str,
|
||||
methods: Iterable[str],
|
||||
path_rules: Iterable[tuple[str, str, str]],
|
||||
ttl_seconds: int = DEFAULT_CAPABILITY_TTL_SECONDS,
|
||||
max_uses: int = 1, now: float | None = None) -> None:
|
||||
self.id = uuid.uuid4().hex[:16]
|
||||
self.connector = connector
|
||||
self.host = (host or "").lower()
|
||||
self.methods = frozenset(methods)
|
||||
self.path_rules = tuple(path_rules)
|
||||
# None = 不限(五档能力的 session/task/timed/permanent
|
||||
# 用;单次档仍是 int,行为不变)。
|
||||
self.expires_at: float | None = (
|
||||
(now if now is not None else time.time())
|
||||
+ ttl_seconds)
|
||||
self.max_uses: int | None = max_uses
|
||||
self.uses_left: int | None = max_uses
|
||||
|
||||
def matches(self, request: EgressRequest,
|
||||
now: float | None = None) -> bool:
|
||||
"""该请求是否在能力绑定内(不消耗次数,只判定)。"""
|
||||
if request.connector != self.connector:
|
||||
return False
|
||||
if request.host != self.host:
|
||||
return False
|
||||
if request.method not in self.methods:
|
||||
return False
|
||||
if not _path_allowed(request, self.path_rules):
|
||||
return False
|
||||
at = now if now is not None else time.time()
|
||||
if self.expires_at is not None and at >= self.expires_at:
|
||||
return False
|
||||
if self.uses_left is not None and self.uses_left <= 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
def use(self, request: EgressRequest,
|
||||
now: float | None = None) -> bool:
|
||||
"""消耗一次能力:绑定内才扣次数并返回 True,否则 False
|
||||
(不抛错,调用方按 deny 处理)。"""
|
||||
if not self.matches(request, now=now):
|
||||
return False
|
||||
if self.uses_left is not None:
|
||||
self.uses_left -= 1
|
||||
return True
|
||||
|
||||
|
||||
class Decision:
|
||||
"""一次审批决策:allow / deny / ask + 原因 + 能力(仅 allow
|
||||
时有)+ 审计记录(无值)。"""
|
||||
|
||||
def __init__(self, verdict: str, reasons: Iterable[str],
|
||||
capability: Capability | None = None,
|
||||
tainted: bool = False) -> None:
|
||||
assert verdict in ("allow", "deny", "ask")
|
||||
self.verdict = verdict
|
||||
self.reasons = list(reasons)
|
||||
self.capability = capability
|
||||
self.tainted = tainted
|
||||
|
||||
def audit(self, request: EgressRequest) -> dict:
|
||||
"""审计记录:事件名按 verdict,detail 只含目标描述与原因。"""
|
||||
record = request.describe()
|
||||
record["event"] = "sentinel_egress_" + self.verdict
|
||||
record["verdict"] = self.verdict
|
||||
record["reasons"] = list(self.reasons)
|
||||
if self.tainted:
|
||||
record["tainted"] = True
|
||||
if self.capability is not None:
|
||||
record["capability_id"] = self.capability.id
|
||||
return record
|
||||
|
||||
|
||||
def _path_allowed(request: EgressRequest, rules) -> bool:
|
||||
"""路径是否在该连接器的 L7 规则内。"""
|
||||
for method, kind, pattern in rules:
|
||||
if request.method != method:
|
||||
continue
|
||||
if kind == "prefix" and request.path.startswith(pattern):
|
||||
return True
|
||||
if kind == "suffix" and request.path.endswith(pattern):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _scope_str(scope: object | None, key: str) -> str | None:
|
||||
"""从 scope(dict 或带属性的对象)取一键,只认 str,
|
||||
别的当缺失(fail-closed,不猜)。"""
|
||||
if scope is None:
|
||||
return None
|
||||
value = scope.get(key) if isinstance(scope, dict) \
|
||||
else getattr(scope, key, None)
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _one_shot_capability(request: EgressRequest, rules: tuple,
|
||||
now: float | None) -> Capability:
|
||||
"""P3 默认能力:单次、300 秒。"""
|
||||
return Capability(
|
||||
request.connector, request.host, {request.method}, rules,
|
||||
ttl_seconds=DEFAULT_CAPABILITY_TTL_SECONDS,
|
||||
max_uses=1, now=now)
|
||||
|
||||
|
||||
def _issue_capability(request: EgressRequest, rules: tuple,
|
||||
tier: str, scope: object | None,
|
||||
now: float | None) -> tuple[Capability, bool]:
|
||||
"""按档位签发严格能力;非法组合(缺用例/会话 id、
|
||||
timed 缺 ttl、permanent 未显式开启)回落单次(更严格)
|
||||
并报告 fallback=True,不抛错。"""
|
||||
if tier == "one-shot":
|
||||
return _one_shot_capability(request, rules, now), False
|
||||
from cloud.chv.capabilities import CapabilityIssuer
|
||||
issuer = CapabilityIssuer()
|
||||
try:
|
||||
cap = issuer.issue(
|
||||
tier, request.connector, request.host,
|
||||
{request.method}, rules,
|
||||
use_case=_scope_str(scope, "use_case") or "",
|
||||
session_id=_scope_str(scope, "session_id"),
|
||||
task_id=_scope_str(scope, "task_id"), now=now)
|
||||
except ValueError:
|
||||
return _one_shot_capability(request, rules, now), True
|
||||
return cap, False
|
||||
|
||||
|
||||
def _is_tainted(tracker: _TaintTracker | None,
|
||||
pid: int | None) -> bool:
|
||||
"""该 pid 是否已污点:未接追踪器或未给 pid 即 False
|
||||
(不阻断评估,污点只关自动放行)。"""
|
||||
if tracker is None or pid is None:
|
||||
return False
|
||||
return bool(tracker.is_tainted(pid))
|
||||
|
||||
|
||||
def _safety_verdict(policy: "SentinelPolicy",
|
||||
request: EgressRequest, rules: tuple,
|
||||
resolve: Callable[[str, int], list] | None
|
||||
) -> Decision | None:
|
||||
"""步骤 2–6:scheme/端口/拒绝名单/解析失败/SSRF 私网
|
||||
IP → deny;全部通过返回 None(继续往下走)。"""
|
||||
if request.scheme not in _CONNECTOR_SCHEMES.get(
|
||||
request.connector, frozenset()):
|
||||
return Decision("deny", ["scheme_not_allowed"])
|
||||
if request.port not in _CONNECTOR_PORTS.get(
|
||||
request.connector, frozenset()):
|
||||
return Decision("deny", ["port_not_allowed"])
|
||||
if request.host in policy.deny_hosts:
|
||||
return Decision("deny", ["host_denied"])
|
||||
resolver = resolve or _default_resolve
|
||||
try:
|
||||
ips = resolver(request.host, request.port)
|
||||
except Exception:
|
||||
return Decision("deny", ["dns_resolution_failed"])
|
||||
if not ips:
|
||||
return Decision("deny", ["dns_resolution_failed"])
|
||||
for ip in ips:
|
||||
net = _blocked_network_for(ip)
|
||||
if net is not None:
|
||||
return Decision("deny", ["ssrf_private_ip:%s" % net])
|
||||
return None
|
||||
|
||||
|
||||
def _auto_allow_decision(request: EgressRequest, rules: tuple,
|
||||
default_policy: _DefaultPolicy | None,
|
||||
tainted: bool, tier: str,
|
||||
scope: object | None,
|
||||
now: float | None) -> Decision | None:
|
||||
"""默认策略自动放行:未接策略、污点进程、不合候选条件
|
||||
时返回 None(走正常审批流);命中即 allow + 按档位签发
|
||||
能力(仍须过连接器 L7 路径规则)。"""
|
||||
if default_policy is None:
|
||||
return None
|
||||
if not default_policy.auto_allow_eligible(request, tainted):
|
||||
return None
|
||||
if not _path_allowed(request, rules):
|
||||
return None
|
||||
capability, fallback = _issue_capability(
|
||||
request, rules, tier, scope, now)
|
||||
reasons = ["policy_auto_allow"]
|
||||
if fallback:
|
||||
reasons.append("capability_tier_fallback")
|
||||
return Decision("allow", reasons, capability=capability,
|
||||
tainted=tainted)
|
||||
|
||||
|
||||
class SentinelPolicy:
|
||||
"""Sentinel 策略:白名单主机 + 拒绝主机 + 连接器 L7 规则。
|
||||
|
||||
默认拒绝(default-deny):主机不在白名单、也不在拒绝名单时
|
||||
返回 ask(不是 allow);拒绝名单命中、SSRF 命中、scheme/
|
||||
端口/路径越界时返回 deny。白名单只放行“已知且解析干净”的
|
||||
目标——白名单不是“解析后是什么都行”,SSRF 检查对白名单
|
||||
主机同样生效(DNS 可被投毒,白名单主机解析到私网 IP 仍 deny)。"""
|
||||
|
||||
def __init__(self, allow_hosts: Iterable[str] = (),
|
||||
deny_hosts: Iterable[str] = (),
|
||||
connector_paths: dict[str, tuple] | None = None
|
||||
) -> None:
|
||||
self.allow_hosts = frozenset(
|
||||
h.lower() for h in (allow_hosts or ()))
|
||||
self.deny_hosts = frozenset(
|
||||
h.lower() for h in (deny_hosts or ()))
|
||||
self.connector_paths = dict(
|
||||
connector_paths if connector_paths is not None
|
||||
else _CONNECTOR_PATH_RULES)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls: type["SentinelPolicy"], env: dict | None,
|
||||
extra_allow_hosts: Iterable[str] = ()
|
||||
) -> "SentinelPolicy":
|
||||
"""从宿主环境变量建策略:CHV_SENTINEL_ALLOW_HOSTS(逗号
|
||||
分隔主机名)+ 调用方追加的白名单(执行器 wake 预审时把
|
||||
配置的 Gitea 主机追加进来——只放行配置的 Gitea 主机,
|
||||
不是放行任意主机)。"""
|
||||
raw = (env or {}).get("CHV_SENTINEL_ALLOW_HOSTS") or ""
|
||||
hosts = [h.strip().lower() for h in raw.split(",") if h.strip()]
|
||||
hosts += [h.lower() for h in (extra_allow_hosts or ())]
|
||||
return cls(allow_hosts=hosts)
|
||||
|
||||
def evaluate(self, request: EgressRequest,
|
||||
resolve: Callable[[str, int], list] | None = None,
|
||||
now: float | None = None,
|
||||
taint_tracker: _TaintTracker | None = None,
|
||||
pid: int | None = None,
|
||||
default_policy: _DefaultPolicy | None = None,
|
||||
tier: str = "one-shot",
|
||||
scope: object | None = None) -> Decision:
|
||||
"""评估一次出口请求(按顺序,首个命中即定 verdict):
|
||||
|
||||
1–6. 安全检查(未知连接器/scheme/端口/拒绝名单/
|
||||
解析失败/SSRF 私网 IP)→ deny;
|
||||
7. 默认策略自动放行(须接 default_policy;干净进程
|
||||
+ 只读 + 已允许 + 低风险才放行;污点进程一律不放,
|
||||
回到正常审批流);
|
||||
8. 主机不在白名单 → ask;9. 方法/路径越界 → deny;
|
||||
10. 全部通过 → allow + 按档位签发严格能力(默认
|
||||
单次、300 秒;tier/scope 由调用方传)。
|
||||
不传接线参数时行为与 P3 完全一致。
|
||||
"""
|
||||
rules = self.connector_paths.get(request.connector)
|
||||
if rules is None:
|
||||
return Decision("deny", ["unknown_connector"])
|
||||
denied = _safety_verdict(self, request, rules, resolve)
|
||||
if denied is not None:
|
||||
return denied
|
||||
tainted = _is_tainted(taint_tracker, pid)
|
||||
auto = _auto_allow_decision(
|
||||
request, rules, default_policy, tainted,
|
||||
tier, scope, now)
|
||||
if auto is not None:
|
||||
return auto
|
||||
if request.host not in self.allow_hosts:
|
||||
return Decision("ask", ["host_not_allowlisted"],
|
||||
tainted=tainted)
|
||||
if not _path_allowed(request, rules):
|
||||
return Decision("deny", ["path_not_allowed"],
|
||||
tainted=tainted)
|
||||
capability, fallback = _issue_capability(
|
||||
request, rules, tier, scope, now)
|
||||
reasons = ["policy_allow"]
|
||||
if fallback:
|
||||
reasons.append("capability_tier_fallback")
|
||||
return Decision("allow", reasons,
|
||||
capability=capability, tainted=tainted)
|
||||
|
||||
class ApprovalQueue:
|
||||
"""ask 待审批队列(内存态):ask 出现即记入,执行停止。
|
||||
|
||||
设计约束(Muse 口径):审批 UI 不在对话内。本期只留队列与
|
||||
查询/标记接口,不实现任何对话内审批入口,也不自动放行。
|
||||
队列是内存态,进程重启即清——ask 本来就意味着“这次执行
|
||||
已停止”,重启后须重新申请。"""
|
||||
|
||||
def __init__(self, clock: Callable[[], float] | None = None) -> None:
|
||||
self._pending: dict[str, dict] = {}
|
||||
self._clock = clock or time.time
|
||||
|
||||
def record(self, request: EgressRequest,
|
||||
reasons: Iterable[str]) -> str:
|
||||
"""记入一条待审批,返回 pending id(只含目标描述与原因)。"""
|
||||
pending_id = uuid.uuid4().hex[:16]
|
||||
record = request.describe()
|
||||
record["pending_id"] = pending_id
|
||||
record["reasons"] = list(reasons)
|
||||
record["ts"] = self._clock()
|
||||
self._pending[pending_id] = record
|
||||
return pending_id
|
||||
|
||||
def list_pending(self) -> list:
|
||||
"""全部待审批(副本,按记录时间排序)。"""
|
||||
return [dict(v) for _, v in sorted(
|
||||
self._pending.items(), key=lambda kv: kv[1]["ts"])]
|
||||
|
||||
def resolve(self, pending_id: str, verdict: str) -> bool:
|
||||
"""标记一条待审批已处理(verdict 只记 allow/deny,不在
|
||||
这里执行放行——放行须调用方重新走 evaluate)。未知 id
|
||||
返回 False,不抛错。"""
|
||||
if pending_id not in self._pending:
|
||||
return False
|
||||
if verdict not in ("allow", "deny"):
|
||||
return False
|
||||
self._pending.pop(pending_id)
|
||||
return True
|
||||
@@ -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
|
||||
@@ -85,6 +85,12 @@ def _write(path, content=b"x"):
|
||||
return path
|
||||
|
||||
|
||||
def _public_resolver(host, port):
|
||||
"""feat-chv-muse-p9:假解析器(公网 IP),供推理出口预审用;
|
||||
实验室绝不联网,真实解析只在真机发生。"""
|
||||
return ["93.184.216.34"]
|
||||
|
||||
|
||||
class CloseupCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
@@ -113,7 +119,11 @@ class CloseupCase(unittest.TestCase):
|
||||
env_extra=None, **overrides):
|
||||
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
|
||||
"CHV_HOST_KEY_MODE": "keyscan",
|
||||
"CHV_SSH_FORWARD": "1"}
|
||||
"CHV_SSH_FORWARD": "1",
|
||||
# feat-chv-muse-p9:推理出口预审的假 origin(公网
|
||||
# https,配假解析器;实验室不起真 VM、不联网)。
|
||||
"SUB2API_BASE_URL": "https://xgapi.lol",
|
||||
"CHV_INFERENCE_ORIGIN": "https://xgapi.lol"}
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
kwargs = dict(
|
||||
@@ -127,6 +137,7 @@ class CloseupCase(unittest.TestCase):
|
||||
sleeper=lambda s: None, env=env)
|
||||
kwargs.update(overrides)
|
||||
ex = CloudHypervisorExecutor(**kwargs)
|
||||
ex.sentinel_resolver = _public_resolver
|
||||
ex.audit_callback = lambda e, t, d, k=None: self.audit.append(
|
||||
(e, t.get("id") if isinstance(t, dict) else t, d, k))
|
||||
return ex
|
||||
|
||||
@@ -73,6 +73,12 @@ def _write(path, content=b"x"):
|
||||
return path
|
||||
|
||||
|
||||
def _public_resolver(host, port):
|
||||
"""feat-chv-muse-p9:假解析器(公网 IP),供推理出口预审用;
|
||||
实验室绝不联网,真实解析只在真机发生。"""
|
||||
return ["93.184.216.34"]
|
||||
|
||||
|
||||
class ChvFixCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
@@ -127,7 +133,11 @@ class ChvFixCase(unittest.TestCase):
|
||||
|
||||
def make_exec(self, runner=None, env_extra=None, **overrides):
|
||||
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
|
||||
"CHV_SSH_FORWARD": "1"}
|
||||
"CHV_SSH_FORWARD": "1",
|
||||
# feat-chv-muse-p9:推理出口预审的假 origin(公网
|
||||
# https,配假解析器;实验室不起真 VM、不联网)。
|
||||
"SUB2API_BASE_URL": "https://xgapi.lol",
|
||||
"CHV_INFERENCE_ORIGIN": "https://xgapi.lol"}
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
kwargs = dict(
|
||||
@@ -141,6 +151,7 @@ class ChvFixCase(unittest.TestCase):
|
||||
sleeper=lambda s: None, env=env)
|
||||
kwargs.update(overrides)
|
||||
ex = CloudHypervisorExecutor(**kwargs)
|
||||
ex.sentinel_resolver = _public_resolver
|
||||
ex.audit_callback = lambda e, t, d, k=None: self.audit.append(
|
||||
(e, t.get("id") if isinstance(t, dict) else t, d, k))
|
||||
return ex
|
||||
@@ -305,8 +316,12 @@ class GatewayExtraEnvTest(ChvFixCase):
|
||||
task = {"id": "gw1", "project_id": "p1", "prompt": "做目标",
|
||||
"goal_mode": 1,
|
||||
"_gateway_provider": "sub2api",
|
||||
# feat-chv-muse-p9:网关 URL 须是合法推理 origin——
|
||||
# 这里用文档形状的本地中继(http + Docker 桥接 IP),
|
||||
# 预审走本地中继分支放行(字面 IP,不查 DNS)。
|
||||
"_gateway_env": {"SUB2API_API_KEY": "gwkey",
|
||||
"SUB2API_BASE_URL": "http://gw/"}}
|
||||
"SUB2API_BASE_URL":
|
||||
"http://172.17.0.1:28084"}}
|
||||
result, error = worker._run_attempt(task)
|
||||
self.assertIsNone(error)
|
||||
self.assertEqual(result["status"], "completed")
|
||||
|
||||
@@ -81,6 +81,12 @@ def _write(path, content=b"x"):
|
||||
return path
|
||||
|
||||
|
||||
def _public_resolver(host, port):
|
||||
"""feat-chv-muse-p9:假解析器(公网 IP),供推理出口预审用;
|
||||
实验室绝不联网,真实解析只在真机发生。"""
|
||||
return ["93.184.216.34"]
|
||||
|
||||
|
||||
class ChvGapCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
@@ -129,7 +135,11 @@ class ChvGapCase(unittest.TestCase):
|
||||
# genisoimage,测的不是 host key 模式,故显式
|
||||
# 声明实验室路径,绝不把它写成生产默认。
|
||||
"CHV_HOST_KEY_MODE": "keyscan",
|
||||
"CHV_SSH_FORWARD": "1"}
|
||||
"CHV_SSH_FORWARD": "1",
|
||||
# feat-chv-muse-p9:推理出口预审的假 origin(公网
|
||||
# https,配假解析器;实验室不起真 VM、不联网)。
|
||||
"SUB2API_BASE_URL": "https://xgapi.lol",
|
||||
"CHV_INFERENCE_ORIGIN": "https://xgapi.lol"}
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
kwargs = dict(
|
||||
@@ -143,6 +153,7 @@ class ChvGapCase(unittest.TestCase):
|
||||
sleeper=lambda s: None, env=env)
|
||||
kwargs.update(overrides)
|
||||
ex = CloudHypervisorExecutor(**kwargs)
|
||||
ex.sentinel_resolver = _public_resolver
|
||||
ex.audit_callback = lambda e, t, d, k=None: self.audit.append(
|
||||
(e, t.get("id") if isinstance(t, dict) else t, d, k))
|
||||
return ex
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
"""对齐 Muse P9:推理与遥测受限通道。
|
||||
|
||||
全部用标准库与 mock,不启动真实 VM、不联网、不碰 WSL/真机;
|
||||
解析器一律用假解析器(公网 IP / 私网 IP 两档)。
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
try:
|
||||
from cloud.chv.executor import CloudHypervisorExecutor
|
||||
from cloud.chv.inference_channel import (
|
||||
InferenceLimits,
|
||||
check_inference_call,
|
||||
inference_allow_hosts,
|
||||
inference_limits,
|
||||
resolve_inference_origin,
|
||||
)
|
||||
from cloud.chv.sentinel import SentinelPolicy
|
||||
from cloud.chv.telemetry import (
|
||||
build_task_telemetry,
|
||||
build_trajectory_event,
|
||||
emit_telemetry,
|
||||
sanitize_telemetry_event,
|
||||
sampled,
|
||||
set_training_enabled,
|
||||
telemetry_sample_rate,
|
||||
training_enabled,
|
||||
)
|
||||
from cloud.sandbox import ChvFailure
|
||||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.chv.executor import CloudHypervisorExecutor
|
||||
from cloud.chv.inference_channel import (
|
||||
InferenceLimits,
|
||||
check_inference_call,
|
||||
inference_allow_hosts,
|
||||
inference_limits,
|
||||
resolve_inference_origin,
|
||||
)
|
||||
from cloud.chv.sentinel import SentinelPolicy
|
||||
from cloud.chv.telemetry import (
|
||||
build_task_telemetry,
|
||||
build_trajectory_event,
|
||||
emit_telemetry,
|
||||
sanitize_telemetry_event,
|
||||
sampled,
|
||||
set_training_enabled,
|
||||
telemetry_sample_rate,
|
||||
training_enabled,
|
||||
)
|
||||
from cloud.sandbox import ChvFailure
|
||||
|
||||
|
||||
def _public_resolver(host, port):
|
||||
"""假解析器:公网 IP。"""
|
||||
return ["93.184.216.34"]
|
||||
|
||||
|
||||
def _private_resolver(host, port):
|
||||
"""假解析器:私网 IP(SSRF 场景)。"""
|
||||
return ["10.0.0.5"]
|
||||
|
||||
|
||||
def _load_host_module():
|
||||
"""以独立模块名加载仓库根的 host.py(只用标准库)。"""
|
||||
path = Path(__file__).resolve().parents[2] / "host.py"
|
||||
spec = importlib.util.spec_from_file_location("host_under_test", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class OriginTest(unittest.TestCase):
|
||||
def test_valid_sub2api_origin(self):
|
||||
env = {"SUB2API_BASE_URL": "https://xgapi.lol/"}
|
||||
self.assertEqual(
|
||||
resolve_inference_origin("sub2api", env), "https://xgapi.lol")
|
||||
|
||||
def test_missing_origin_returns_empty(self):
|
||||
self.assertEqual(resolve_inference_origin("sub2api", {}), "")
|
||||
self.assertEqual(resolve_inference_origin(None, {}), "")
|
||||
|
||||
def test_http_origin_rejected(self):
|
||||
env = {"SUB2API_BASE_URL": "http://xgapi.lol"}
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_inference_origin("sub2api", env)
|
||||
|
||||
def test_http_public_ip_rejected(self):
|
||||
env = {"SUB2API_BASE_URL": "http://8.8.8.8:28084"}
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_inference_origin("sub2api", env)
|
||||
|
||||
def test_http_hostname_rejected(self):
|
||||
env = {"SUB2API_BASE_URL": "http://relay.local:28084"}
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_inference_origin("sub2api", env)
|
||||
|
||||
def test_http_local_relay_allowed(self):
|
||||
# 文档形状:本地 sub2api 中继走 Docker 桥接 IP + http,
|
||||
# 端口保留;环回同款。
|
||||
env = {"SUB2API_BASE_URL": "http://172.17.0.1:28084/"}
|
||||
self.assertEqual(
|
||||
resolve_inference_origin("sub2api", env),
|
||||
"http://172.17.0.1:28084")
|
||||
env2 = {"SUB2API_BASE_URL": "http://127.0.0.1:28084"}
|
||||
self.assertEqual(
|
||||
resolve_inference_origin("sub2api", env2),
|
||||
"http://127.0.0.1:28084")
|
||||
|
||||
def test_userinfo_in_origin_rejected(self):
|
||||
env = {"SUB2API_BASE_URL": "https://user:pass@xgapi.lol"}
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_inference_origin("sub2api", env)
|
||||
|
||||
def test_non_443_port_rejected(self):
|
||||
env = {"SUB2API_BASE_URL": "https://xgapi.lol:8443"}
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_inference_origin("sub2api", env)
|
||||
|
||||
def test_unknown_provider_falls_back_to_default_origin(self):
|
||||
# 未知标签与 host.py `_server_command` 一致走默认网关;
|
||||
# 未配 CHV_INFERENCE_ORIGIN 时返回 ""(调用方按 ask 处理)。
|
||||
env = {"CHV_INFERENCE_ORIGIN": "https://xgapi.lol"}
|
||||
self.assertEqual(
|
||||
resolve_inference_origin("openai", env), "https://xgapi.lol")
|
||||
self.assertEqual(resolve_inference_origin("openai", {}), "")
|
||||
|
||||
def test_xcmapi_origin_from_explicit_env(self):
|
||||
env = {"CHV_INFERENCE_ORIGIN": "https://xgapi.lol"}
|
||||
self.assertEqual(
|
||||
resolve_inference_origin(None, env), "https://xgapi.lol")
|
||||
|
||||
|
||||
class AllowlistTest(unittest.TestCase):
|
||||
def test_explicit_allowlist_wins(self):
|
||||
env = {"SUB2API_BASE_URL": "https://xgapi.lol",
|
||||
"CHV_INFERENCE_ALLOW_HOSTS": "a.example, B.example "}
|
||||
hosts = inference_allow_hosts("sub2api", env)
|
||||
self.assertEqual(hosts, frozenset({"a.example", "b.example"}))
|
||||
|
||||
def test_default_allowlist_is_origin_host(self):
|
||||
env = {"SUB2API_BASE_URL": "https://xgapi.lol"}
|
||||
self.assertEqual(inference_allow_hosts("sub2api", env),
|
||||
frozenset({"xgapi.lol"}))
|
||||
|
||||
def test_no_origin_means_empty_allowlist(self):
|
||||
self.assertEqual(inference_allow_hosts("sub2api", {}), frozenset())
|
||||
|
||||
|
||||
class LimitsTest(unittest.TestCase):
|
||||
def test_defaults(self):
|
||||
self.assertEqual(inference_limits({}),
|
||||
InferenceLimits(timeout_seconds=120,
|
||||
max_retries=1))
|
||||
|
||||
def test_env_override(self):
|
||||
env = {"CHV_INFERENCE_TIMEOUT": "60",
|
||||
"CHV_INFERENCE_MAX_RETRIES": "3"}
|
||||
self.assertEqual(inference_limits(env),
|
||||
InferenceLimits(timeout_seconds=60, max_retries=3))
|
||||
|
||||
def test_invalid_timeout_rejected(self):
|
||||
for bad in ("abc", "5", "601", "-1"):
|
||||
with self.assertRaises(ValueError):
|
||||
inference_limits({"CHV_INFERENCE_TIMEOUT": bad})
|
||||
|
||||
def test_invalid_retries_rejected(self):
|
||||
for bad in ("x", "-1", "6"):
|
||||
with self.assertRaises(ValueError):
|
||||
inference_limits({"CHV_INFERENCE_MAX_RETRIES": bad})
|
||||
|
||||
|
||||
class PreflightTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.env = {"SUB2API_BASE_URL": "https://xgapi.lol",
|
||||
"CHV_INFERENCE_ALLOW_HOSTS": "xgapi.lol"}
|
||||
|
||||
def test_allow_with_public_ip(self):
|
||||
check = check_inference_call(
|
||||
"sub2api", self.env, resolve=_public_resolver)
|
||||
self.assertEqual(check.decision.verdict, "allow")
|
||||
self.assertIsNotNone(check.decision.capability)
|
||||
self.assertTrue(check.decision.capability.matches(check.request))
|
||||
self.assertEqual(check.request.host, "xgapi.lol")
|
||||
self.assertEqual(check.request.path, "/v1/responses")
|
||||
|
||||
def test_capability_single_use_300s(self):
|
||||
check = check_inference_call(
|
||||
"sub2api", self.env, resolve=_public_resolver, now=1000.0)
|
||||
cap = check.decision.capability
|
||||
self.assertTrue(cap.use(check.request, now=1000.0))
|
||||
self.assertFalse(cap.use(check.request, now=1000.0))
|
||||
self.assertFalse(cap.matches(check.request, now=2000.0))
|
||||
|
||||
def test_local_relay_allow_without_dns(self):
|
||||
# 本地中继是字面私网 IP:不查 DNS(解析器被调即错),
|
||||
# allowlist 默认即 origin 主机,直接 allow + 单次能力。
|
||||
def _must_not_resolve(host, port):
|
||||
raise AssertionError("local relay must not resolve")
|
||||
env = {"SUB2API_BASE_URL": "http://172.17.0.1:28084"}
|
||||
check = check_inference_call(
|
||||
"sub2api", env, resolve=_must_not_resolve)
|
||||
self.assertEqual(check.decision.verdict, "allow")
|
||||
self.assertIn("local_relay", check.decision.reasons)
|
||||
self.assertIsNotNone(check.decision.capability)
|
||||
self.assertEqual(check.request.host, "172.17.0.1")
|
||||
self.assertEqual(check.request.port, 28084)
|
||||
self.assertEqual(check.request.scheme, "http")
|
||||
|
||||
def test_local_relay_ask_when_not_allowlisted(self):
|
||||
env = {"SUB2API_BASE_URL": "http://172.17.0.1:28084",
|
||||
"CHV_INFERENCE_ALLOW_HOSTS": "xgapi.lol"}
|
||||
check = check_inference_call(
|
||||
"sub2api", env, resolve=_public_resolver)
|
||||
self.assertEqual(check.decision.verdict, "ask")
|
||||
|
||||
def test_deny_on_private_ip(self):
|
||||
check = check_inference_call(
|
||||
"sub2api", self.env, resolve=_private_resolver)
|
||||
self.assertEqual(check.decision.verdict, "deny")
|
||||
self.assertIn("ssrf_private_ip", check.decision.reasons[0])
|
||||
|
||||
def test_ask_on_non_allowlisted_host(self):
|
||||
# 显式 allowlist 已配、origin 主机不在其中 → ask。
|
||||
# (未配 allowlist 时默认即 origin 主机自身,不会 ask——
|
||||
# 见 test_default_allowlist_is_origin_host。)
|
||||
env = {"SUB2API_BASE_URL": "https://unknown.example",
|
||||
"CHV_INFERENCE_ALLOW_HOSTS": "xgapi.lol"}
|
||||
check = check_inference_call(
|
||||
"sub2api", env, resolve=_public_resolver)
|
||||
self.assertEqual(check.decision.verdict, "ask")
|
||||
|
||||
def test_ask_when_origin_not_configured(self):
|
||||
check = check_inference_call(
|
||||
None, {}, resolve=_public_resolver)
|
||||
self.assertEqual(check.decision.verdict, "ask")
|
||||
self.assertIn("inference_origin_not_configured",
|
||||
check.decision.reasons)
|
||||
|
||||
def test_xcmapi_origin_allow(self):
|
||||
env = {"CHV_INFERENCE_ORIGIN": "https://xgapi.lol",
|
||||
"CHV_INFERENCE_ALLOW_HOSTS": "xgapi.lol"}
|
||||
check = check_inference_call(
|
||||
None, env, resolve=_public_resolver)
|
||||
self.assertEqual(check.decision.verdict, "allow")
|
||||
|
||||
def test_unknown_provider_falls_back_to_default_origin(self):
|
||||
# 未知标签不 deny:与 _server_command 一致走默认网关;
|
||||
# 未配 CHV_INFERENCE_ORIGIN 时仍是 ask(fail-closed)。
|
||||
env = {"CHV_INFERENCE_ORIGIN": "https://xgapi.lol",
|
||||
"CHV_INFERENCE_ALLOW_HOSTS": "xgapi.lol"}
|
||||
check = check_inference_call(
|
||||
"prov-x", env, resolve=_public_resolver)
|
||||
self.assertEqual(check.decision.verdict, "allow")
|
||||
check2 = check_inference_call(
|
||||
"prov-x", {}, resolve=_public_resolver)
|
||||
self.assertEqual(check2.decision.verdict, "ask")
|
||||
|
||||
def test_deny_path_out_of_scope(self):
|
||||
policy = SentinelPolicy(
|
||||
allow_hosts={"xgapi.lol"},
|
||||
connector_paths={"inference": (("POST", "prefix",
|
||||
"/v1/responses"),)})
|
||||
from cloud.chv.sentinel import EgressRequest
|
||||
request = EgressRequest("inference", "GET", "https",
|
||||
"xgapi.lol", 443, "/v1/admin")
|
||||
decision = policy.evaluate(request, resolve=_public_resolver)
|
||||
self.assertEqual(decision.verdict, "deny")
|
||||
|
||||
def test_audit_record_has_no_values(self):
|
||||
check = check_inference_call(
|
||||
"sub2api", self.env, resolve=_public_resolver)
|
||||
record = check.decision.audit(check.request)
|
||||
blob = json.dumps(record)
|
||||
self.assertNotIn("xgapi.lol".replace("xgapi", "SECRET"), blob)
|
||||
self.assertEqual(record["host"], "xgapi.lol")
|
||||
self.assertEqual(record["verdict"], "allow")
|
||||
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, returncode=0, stdout="", stderr=""):
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
def _write(path, content=b"x"):
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(content)
|
||||
os.chmod(path, 0o755)
|
||||
return path
|
||||
|
||||
|
||||
class GateTest(unittest.TestCase):
|
||||
"""执行器预审:deny/ask 时不起 VM(wake 未被调用);allow 才继续。"""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.dir = self.tmp.name
|
||||
self.binary = _write(os.path.join(self.dir, "cloud-hypervisor"))
|
||||
self.ch_remote = _write(os.path.join(self.dir, "ch-remote"))
|
||||
self.kernel = _write(os.path.join(self.dir, "vmlinux"))
|
||||
self.rootfs = _write(os.path.join(self.dir, "disk.raw"), b"R" * 100)
|
||||
self.ssh_key = _write(os.path.join(self.dir, "id_chv"))
|
||||
self.kvm = _write(os.path.join(self.dir, "kvm"))
|
||||
self.cpuinfo = os.path.join(self.dir, "cpuinfo")
|
||||
with open(self.cpuinfo, "w") as fh:
|
||||
fh.write("flags : fpu vmx sse\n")
|
||||
self.state_dir = os.path.join(self.dir, "state")
|
||||
self.wake_calls = []
|
||||
self.audit = []
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _make_exec(self, env_extra):
|
||||
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
|
||||
"CHV_HOST_KEY_MODE": "keyscan",
|
||||
"CHV_SSH_FORWARD": "1"}
|
||||
env.update(env_extra)
|
||||
ex = CloudHypervisorExecutor(
|
||||
binary=self.binary, ch_remote=self.ch_remote,
|
||||
kernel=self.kernel, rootfs=self.rootfs,
|
||||
ssh_key=self.ssh_key, kvm_path=self.kvm,
|
||||
cpuinfo_path=self.cpuinfo, state_dir=self.state_dir,
|
||||
runner=lambda cmd, timeout=None: FakeResult(0, "", ""),
|
||||
launcher=lambda cmd: FakeResult(0, "", ""),
|
||||
api_client=lambda *a, **k: FakeResult(0, "", ""),
|
||||
sleeper=lambda s: None, env=env)
|
||||
ex.audit_callback = lambda e, t, d, k=None: self.audit.append(
|
||||
(e, t.get("id") if isinstance(t, dict) else t, d, k))
|
||||
return ex
|
||||
|
||||
def test_deny_blocks_wake(self):
|
||||
ex = self._make_exec(
|
||||
{"SUB2API_BASE_URL": "https://xgapi.lol",
|
||||
"CHV_INFERENCE_ALLOW_HOSTS": "xgapi.lol"})
|
||||
ex.sentinel_resolver = _private_resolver
|
||||
ex.wake_vm = mock.Mock(side_effect=AssertionError("must not wake"))
|
||||
task = {"id": "t1", "prompt": "hi"}
|
||||
with self.assertRaises(ChvFailure) as ctx:
|
||||
ex.dispatch_task(task, model_provider="sub2api")
|
||||
self.assertEqual(ctx.exception.kind, "inference_egress_denied")
|
||||
ex.wake_vm.assert_not_called()
|
||||
kinds = [e[0] for e in self.audit]
|
||||
self.assertIn("inference_egress_deny", kinds)
|
||||
|
||||
def test_ask_blocks_wake_when_origin_missing(self):
|
||||
ex = self._make_exec({})
|
||||
ex.sentinel_resolver = _public_resolver
|
||||
ex.wake_vm = mock.Mock(side_effect=AssertionError("must not wake"))
|
||||
task = {"id": "t2", "prompt": "hi"}
|
||||
with self.assertRaises(ChvFailure) as ctx:
|
||||
ex.dispatch_task(task, model_provider=None)
|
||||
self.assertEqual(ctx.exception.kind, "inference_egress_denied")
|
||||
ex.wake_vm.assert_not_called()
|
||||
|
||||
def test_extra_env_origin_is_honored(self):
|
||||
# 网关配置随任务来(worker 的 extra_env),执行器
|
||||
# 进程 env 里没有 origin 也要放行。
|
||||
ex = self._make_exec({})
|
||||
ex.sentinel_resolver = _public_resolver
|
||||
ex.wake_vm = mock.Mock()
|
||||
task = {"id": "t3", "prompt": "hi"}
|
||||
with mock.patch.object(ex, "_run_guest",
|
||||
return_value={"status": "completed"}):
|
||||
with mock.patch.object(ex, "shutdown_vm"):
|
||||
ex.dispatch_task(
|
||||
task, model_provider="sub2api",
|
||||
extra_env={"SUB2API_BASE_URL": "https://xgapi.lol"})
|
||||
ex.wake_vm.assert_called_once()
|
||||
|
||||
def test_bad_origin_becomes_deny_not_value_error(self):
|
||||
ex = self._make_exec({})
|
||||
ex.sentinel_resolver = _public_resolver
|
||||
ex.wake_vm = mock.Mock(side_effect=AssertionError("must not wake"))
|
||||
task = {"id": "t4", "prompt": "hi"}
|
||||
with self.assertRaises(ChvFailure) as ctx:
|
||||
ex.dispatch_task(
|
||||
task, model_provider="sub2api",
|
||||
extra_env={"SUB2API_BASE_URL": "http://xgapi.lol"})
|
||||
self.assertEqual(ctx.exception.kind, "inference_egress_denied")
|
||||
ex.wake_vm.assert_not_called()
|
||||
kinds = [e[0] for e in self.audit]
|
||||
self.assertIn("inference_egress_deny", kinds)
|
||||
|
||||
|
||||
class TelemetryTest(unittest.TestCase):
|
||||
def test_allowlisted_fields_only(self):
|
||||
event = build_task_telemetry(
|
||||
task_id="t1", vm_id="vm1", provider="sub2api",
|
||||
model="gpt-6.1-terra", status="completed",
|
||||
started_at=1000.0, ended_at=1005.0, duration_ms=5000,
|
||||
tokens={"input_tokens": 10, "output_tokens": 5,
|
||||
"total_tokens": 15},
|
||||
turns=2, tool_calls=3, env={"CHV_TELEMETRY_SAMPLE_RATE": "1"})
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual(event["tokens_in"], 10)
|
||||
self.assertEqual(event["tokens_total"], 15)
|
||||
self.assertEqual(event["turns"], 2)
|
||||
|
||||
def test_secret_and_text_keys_dropped(self):
|
||||
event = {"task_id": "t1", "vm_id": "v", "provider": "sub2api",
|
||||
"model": "m", "status": "completed",
|
||||
"started_at": 1.0, "ended_at": 2.0, "duration_ms": 1,
|
||||
"prompt": "do evil", "content": "secret file",
|
||||
"api_key": "sk-123", "user_email": "a@b.c",
|
||||
"extra_blob": {"nested": "data"}}
|
||||
clean = sanitize_telemetry_event(event)
|
||||
for bad in ("prompt", "content", "api_key", "user_email",
|
||||
"extra_blob"):
|
||||
self.assertNotIn(bad, clean)
|
||||
self.assertEqual(clean["task_id"], "t1")
|
||||
|
||||
def test_tokens_none_when_not_reported(self):
|
||||
event = build_task_telemetry(
|
||||
task_id="t1", vm_id="vm1", provider="sub2api", model=None,
|
||||
status="failed", started_at=1.0, ended_at=2.0, duration_ms=1,
|
||||
env={"CHV_TELEMETRY_SAMPLE_RATE": "1"})
|
||||
self.assertIsNone(event["tokens_in"])
|
||||
self.assertIsNone(event["tokens_out"])
|
||||
self.assertIsNone(event["tokens_total"])
|
||||
|
||||
def test_sampling_deterministic(self):
|
||||
self.assertEqual(sampled("task-abc", 1.0), True)
|
||||
self.assertEqual(sampled("task-abc", 0.0), False)
|
||||
self.assertEqual(sampled("task-abc", 0.5),
|
||||
sampled("task-abc", 0.5))
|
||||
event = build_task_telemetry(
|
||||
task_id="t1", vm_id="v", provider="sub2api", model=None,
|
||||
status="completed", started_at=1.0, ended_at=2.0,
|
||||
duration_ms=1, env={"CHV_TELEMETRY_SAMPLE_RATE": "0"})
|
||||
self.assertIsNone(event)
|
||||
|
||||
def test_invalid_sample_rate_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
telemetry_sample_rate({"CHV_TELEMETRY_SAMPLE_RATE": "2"})
|
||||
with self.assertRaises(ValueError):
|
||||
telemetry_sample_rate({"CHV_TELEMETRY_SAMPLE_RATE": "x"})
|
||||
|
||||
def test_emit_never_raises(self):
|
||||
self.assertFalse(emit_telemetry("/nonexistent/dir/xyz",
|
||||
{"task_id": "t1"}))
|
||||
self.assertFalse(emit_telemetry("", {"task_id": "t1"}))
|
||||
|
||||
def test_emit_writes_jsonl(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
event = {"task_id": "t9", "vm_id": "v", "status": "completed"}
|
||||
self.assertTrue(emit_telemetry(d, event))
|
||||
with open(os.path.join(d, "telemetry.jsonl")) as fh:
|
||||
row = json.loads(fh.read().strip())
|
||||
self.assertEqual(row["task_id"], "t9")
|
||||
self.assertNotIn("prompt", row)
|
||||
|
||||
|
||||
class TrainingOptTest(unittest.TestCase):
|
||||
def test_default_enabled(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
self.assertTrue(training_enabled(d))
|
||||
|
||||
def test_set_off_persists_and_blocks_trajectory(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
self.assertTrue(set_training_enabled(d, False))
|
||||
self.assertFalse(training_enabled(d))
|
||||
event = build_trajectory_event(
|
||||
"t1", 3, ["read", "write"], 100, d)
|
||||
self.assertIsNone(event)
|
||||
# 任务级遥测不受开关影响
|
||||
task_event = build_task_telemetry(
|
||||
"t1", "v", "sub2api", None, "completed",
|
||||
1.0, 2.0, 1, env={"CHV_TELEMETRY_SAMPLE_RATE": "1"})
|
||||
self.assertIsNotNone(task_event)
|
||||
|
||||
def test_trajectory_has_metadata_only(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
event = build_trajectory_event(
|
||||
"t1", 2, ["read", "exec"], 50, d)
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual(event["tools"], ["read", "exec"])
|
||||
self.assertNotIn("prompt", event)
|
||||
self.assertNotIn("content", event)
|
||||
|
||||
def test_corrupt_state_fails_closed(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with open(os.path.join(d, "training-opt.json"), "w") as fh:
|
||||
fh.write("{not json")
|
||||
self.assertFalse(training_enabled(d))
|
||||
|
||||
def test_non_bool_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with self.assertRaises(TypeError):
|
||||
set_training_enabled(d, "off")
|
||||
|
||||
def test_switch_change_is_audited(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
set_training_enabled(d, False, actor="tester")
|
||||
audit_path = os.path.join(d, "training-opt-audit.jsonl")
|
||||
with open(audit_path) as fh:
|
||||
row = json.loads(fh.read().strip())
|
||||
self.assertEqual(row["event"], "training_opt_changed")
|
||||
self.assertFalse(row["enabled"])
|
||||
self.assertEqual(row["actor"], "tester")
|
||||
|
||||
|
||||
class HostPyTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.host = _load_host_module()
|
||||
|
||||
def test_server_command_valid_origin(self):
|
||||
with mock.patch.dict(os.environ,
|
||||
{"SUB2API_BASE_URL": "https://xgapi.lol"}):
|
||||
cmd = self.host._server_command("codex", provider="sub2api")
|
||||
self.assertIn("app-server", cmd)
|
||||
self.assertTrue(any("xgapi.lol/v1" in c for c in cmd))
|
||||
|
||||
def test_server_command_http_origin_rejected(self):
|
||||
with mock.patch.dict(os.environ,
|
||||
{"SUB2API_BASE_URL": "http://xgapi.lol"}):
|
||||
with self.assertRaises(ValueError):
|
||||
self.host._server_command("codex", provider="sub2api")
|
||||
|
||||
def test_server_command_local_relay_allowed(self):
|
||||
# 文档形状的本地中继(http + 桥接 IP)必须通过第二道防线。
|
||||
with mock.patch.dict(os.environ,
|
||||
{"SUB2API_BASE_URL":
|
||||
"http://172.17.0.1:28084"}):
|
||||
cmd = self.host._server_command("codex", provider="sub2api")
|
||||
self.assertTrue(any("172.17.0.1:28084/v1" in c for c in cmd))
|
||||
|
||||
def test_server_command_allowlist_mismatch_rejected(self):
|
||||
with mock.patch.dict(os.environ,
|
||||
{"SUB2API_BASE_URL": "https://xgapi.lol",
|
||||
"CHV_INFERENCE_ALLOW_HOSTS": "other.example"}):
|
||||
with self.assertRaises(ValueError):
|
||||
self.host._server_command("codex", provider="sub2api")
|
||||
|
||||
def test_request_timeout_default_and_env(self):
|
||||
self.assertEqual(
|
||||
self.host._inference_request_timeout(None), 120)
|
||||
self.assertEqual(
|
||||
self.host._inference_request_timeout(60), 60)
|
||||
with mock.patch.dict(os.environ,
|
||||
{"CHV_INFERENCE_TIMEOUT": "30"}):
|
||||
self.assertEqual(
|
||||
self.host._inference_request_timeout(None), 30)
|
||||
with mock.patch.dict(os.environ,
|
||||
{"CHV_INFERENCE_TIMEOUT": "5"}):
|
||||
with self.assertRaises(ValueError):
|
||||
self.host._inference_request_timeout(None)
|
||||
|
||||
def test_request_retries_default_and_env(self):
|
||||
self.assertEqual(self.host._inference_max_retries(), 1)
|
||||
with mock.patch.dict(os.environ,
|
||||
{"CHV_INFERENCE_MAX_RETRIES": "0"}):
|
||||
self.assertEqual(self.host._inference_max_retries(), 0)
|
||||
with mock.patch.dict(os.environ,
|
||||
{"CHV_INFERENCE_MAX_RETRIES": "9"}):
|
||||
with self.assertRaises(ValueError):
|
||||
self.host._inference_max_retries()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -86,6 +86,12 @@ def _write(path, content=b"x"):
|
||||
return path
|
||||
|
||||
|
||||
def _public_resolver(host, port):
|
||||
"""feat-chv-muse-p9:假解析器(公网 IP),供推理出口预审用;
|
||||
实验室绝不联网,真实解析只在真机发生。"""
|
||||
return ["93.184.216.34"]
|
||||
|
||||
|
||||
class ChvOptCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
@@ -118,7 +124,11 @@ class ChvOptCase(unittest.TestCase):
|
||||
# genisoimage,测的不是 host key 模式,故显式
|
||||
# 声明实验室路径,绝不把它写成生产默认。
|
||||
"CHV_HOST_KEY_MODE": "keyscan",
|
||||
"CHV_SSH_FORWARD": "1"}
|
||||
"CHV_SSH_FORWARD": "1",
|
||||
# feat-chv-muse-p9:推理出口预审的假 origin(公网
|
||||
# https,配假解析器;实验室不起真 VM、不联网)。
|
||||
"SUB2API_BASE_URL": "https://xgapi.lol",
|
||||
"CHV_INFERENCE_ORIGIN": "https://xgapi.lol"}
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
if runner is None:
|
||||
@@ -135,7 +145,9 @@ class ChvOptCase(unittest.TestCase):
|
||||
runner=runner, launcher=launcher, api_client=api_client,
|
||||
sleeper=lambda s: None, env=env)
|
||||
kwargs.update(overrides)
|
||||
return CloudHypervisorExecutor(**kwargs)
|
||||
ex = CloudHypervisorExecutor(**kwargs)
|
||||
ex.sentinel_resolver = _public_resolver
|
||||
return ex
|
||||
|
||||
def _default_runner(self, cmd, timeout=None):
|
||||
self.calls.append(list(cmd))
|
||||
|
||||
@@ -73,6 +73,12 @@ def _write(path, content=b"x"):
|
||||
return path
|
||||
|
||||
|
||||
def _public_resolver(host, port):
|
||||
"""feat-chv-muse-p9:假解析器(公网 IP),供推理出口预审用;
|
||||
实验室绝不联网,真实解析只在真机发生。"""
|
||||
return ["93.184.216.34"]
|
||||
|
||||
|
||||
class ChvCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
@@ -106,7 +112,11 @@ class ChvCase(unittest.TestCase):
|
||||
# genisoimage,测的不是 host key 模式,故显式
|
||||
# 声明实验室路径,绝不把它写成生产默认。
|
||||
"CHV_HOST_KEY_MODE": "keyscan",
|
||||
"CHV_SSH_FORWARD": "1"}
|
||||
"CHV_SSH_FORWARD": "1",
|
||||
# feat-chv-muse-p9:推理出口预审的假 origin(公网
|
||||
# https,配假解析器;实验室不起真 VM、不联网)。
|
||||
"SUB2API_BASE_URL": "https://xgapi.lol",
|
||||
"CHV_INFERENCE_ORIGIN": "https://xgapi.lol"}
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
if runner is None:
|
||||
@@ -124,6 +134,7 @@ class ChvCase(unittest.TestCase):
|
||||
sleeper=lambda s: None, env=env)
|
||||
kwargs.update(overrides)
|
||||
ex = CloudHypervisorExecutor(**kwargs)
|
||||
ex.sentinel_resolver = _public_resolver
|
||||
ex.audit_callback = lambda e, t, d, k=None: self.audit.append(
|
||||
(e, t.get("id") if isinstance(t, dict) else t, d, k))
|
||||
return ex
|
||||
|
||||
Reference in new issue
Block a user