feat-chv-muse-p9: 推理与遥测受限通道(Sentinel 预审 + 遥测最小化 + 训练退出开关)

- 受限推理通道:cloud/chv/inference_channel.py(origin 解析、
  allowlist、上限、Sentinel 预审),执行器 _run_with_vm 起 VM 前
  必过 _inference_gate(allow 才继续,deny/ask 审计 + fail-closed,
  任务级 extra_env 合并进预审);host.py 第二道防线(_server_command
  的 origin 结构校验 + Server.request 超时/重试走 env)。
  origin 规则:https 公网网关(只 443);http 只接受本地中继
  (字面环回/私网 IP,如 http://172.17.0.1:28084,端口保留,
  无 DNS、无重绑定,allowlist 与单次能力照常);未知 provider
  标签走默认网关(与 _server_command 一致)。
- sentinel.py:从 P5(25bcd35)取的超集版本 + 推理连接器条目
  (POST /v1/responses、GET /v1/models;scheme/端口只 https/443,
  本地中继不走 evaluate,不把 http 加进表)。
- 遥测最小化:cloud/chv/telemetry.py(字段白名单、确定性采样、
  落盘失败不影响任务、token 未回传记 null)+ 训练退出开关
  (默认开,本项目决定;关后轨迹不再产生;损坏 fail-closed;变更审计)。
- 访问边界:docs/chv-access-boundary.md(运营访问口径 + 审计钩子,
  不冒充技术不可访问;Confidential VM 明确未做)。
- 测试:cloud/tests/test_chv_muse_p9.py 51 组全过;5 个旧文件
  的 make_exec 配假 origin + 假解析器(新 fail-closed 门要求的
  测试配置);全量 cloud 套件 1918 OK(skipped=8)。
  详见 docs/chv-muse-p9-gap.md(含自定清单与真机待验证清单)。
This commit is contained in:
host committed 2026-11-06 23:03:06 +00:00
1 parent 06c3e172fe
commit c06ba50983
13 files changed
+1922 -11

No files matched your search

+486
View File
@@ -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