feat(chv): 对齐 Muse P3 收尾——authd 替身凭证 + Sentinel 出口审批

- 新测试 cloud/tests/test_chv_muse_p3.py 30 项:替身过期、真 token
  泄露面、SSRF 挡截、deny/ask 路径、authd-seed 拆分、wake 预审、
  客内 authd 守护进程、git credential helper 端到端(真 git fill)
- 8 个旧测试按新 authd 行为改写(未删测试、未放宽断言):
  GITEA_TOKEN 改走 authd-seed,build_ssh_command 不再 source 进
  codex 环境;wake 测试补 Sentinel 白名单 + 假解析器
- 7 处超 50 行函数拆到 50 行内(不改行为);补 authd/sentinel
  公共 API 注解,mypy 在改动文件上干净
- 真问题修复:git 默认不把 path 发给 helper,chv-sync boot 加
  credential.useHttpPath=true,否则 authd 路径每次都拒绝
- 全量 cloud 套件 1897 项 OK(skipped=8)

真机待验证(未做,等第三棒让出 WSL 测试机):见
docs/chv-muse-p3-gap.md §3.6 清单
This commit is contained in:
host committed 2026-11-06 21:21:55 +00:00
1 parent 06c3e172fe
commit a90cf78d84
15 files changed
+2370 -93

No files matched your search

+311
View File
@@ -0,0 +1,311 @@
"""authd 式替身凭证服务(feat-chv-muse-p3,叶子模块)。
对齐 Muse 官方安全文(security.muse.ai 2026-09-08,见
docs/chv-muse-p3-gap.md):凭证由 VM 内独立服务 authd 保管
(含 OAuth token,存于用户 VM 而非集中基础设施),代理只见
替身令牌;内置连接器的执行分离——每个 privsep worker 有明确
凭证白名单(日历 worker 不能仅改参数就索取邮件凭证);登录
凭证存入运行时单元之外的安全凭证存储,代理不可见,仅在需要
时注入。
本模块是纯逻辑(只用标准库,不依赖 cloud.*),宿主侧与客内
authd 守护进程共用同一份代码(见 chv-guest/authd.py):
- `CredentialVault`:只活内存的保险库;`store()` 只接受白名单
内的(连接器,凭证键);`release()` 按次发放,绑定
{连接器, 主机, 仓库路径, 操作},单次有效 + 300 秒 TTL,
过期即废;`__repr__` 与审计记录里绝无凭证值。
- 替身的两档(见差距表 §1):本期默认是“按次释放”——释放给
git 子进程的值仍是真 token,但只进 git 进程内存、不进 agent
环境/argv/日志/落盘;真铸 token(Gitea API 铸短期专用 token)
由 `mint_gitea_token()` 实现,默认关闭(须真 token 有建 token
权限,现有 write:repository 测试 token 铸 token 会失败)。
- 位置决策:authd 跑在客内、单元外(root 身份的 systemd 服务),
不在宿主侧——Muse 的 authd 就在用户 VM 里;宿主侧方案要走
vsock(P1 范围、未就绪),且会把全部 VM 的 token 集中到宿主。
"""
import hashlib
import hmac
import json
import secrets
import time
import urllib.error
import urllib.request
from collections.abc import Callable, Iterable
# 连接器白名单(privsep 口径):本期只认 gitea 一个连接器。
# 未知连接器一律拒绝,不得以“默认放行”扩展。
ALLOWED_CONNECTORS = frozenset({"gitea"})
# 每连接器的凭证白名单:连接器 → 允许的凭证键。gitea 只允许
# GITEA_TOKEN;其它键(哪怕调用方传进来)一律拒绝入库。
CONNECTOR_CREDENTIALS = {
"gitea": frozenset({"GITEA_TOKEN"}),
}
# 按次释放的默认有效期(秒)与次数:单次、300 秒,过期即废。
DEFAULT_RELEASE_TTL_SECONDS = 300
DEFAULT_RELEASE_MAX_USES = 1
class AuthdRefusal(Exception):
"""authd 拒绝:kind + 原因,不含任何凭证值。"""
def __init__(self, kind: str, message: str) -> None:
super().__init__("authd_refused:%s: %s" % (kind, message))
self.kind = kind
class SurrogateRelease:
"""一次按次释放(替身):绑定到一次具体的凭证使用。
release_id 是不透明 id(审计与吊销用,不含值);value 只在
释放对象里出现一次,由调用方(客内守护进程)经 Unix socket
交给 git credential helper、再由 helper 交给 git 子进程——
除此之外不进任何环境、argv、日志、审计。`consume()` 校验
绑定与有效期,单次有效:用过一次即废,过期即废。"""
def __init__(self, release_id: str, connector: str, key: str,
host: str, repo: str, operation: str, value: str,
expires_at: float, max_uses: int = 1) -> None:
self.release_id = release_id
self.connector = connector
self.key = key
self.host = host
self.repo = repo
self.operation = operation
self.value = value
self.expires_at = expires_at
self.uses_left = max_uses
def describe(self) -> dict:
"""审计用描述(只含绑定,不含值)。"""
return {"release_id": self.release_id,
"connector": self.connector, "key": self.key,
"host": self.host, "repo": self.repo,
"operation": self.operation,
"expires_at": self.expires_at,
"uses_left": self.uses_left}
def __repr__(self) -> str: # 绝不含 value
return ("SurrogateRelease(id=%s connector=%s host=%s "
"repo=%s operation=%s)"
% (self.release_id, self.connector, self.host,
self.repo, self.operation))
class CredentialVault:
"""只活内存的凭证保险库(一个 VM 一个实例,客内 authd 持有)。
- 值只存在 `self._store` 的内存 dict 里,不落盘、不进日志;
- `store()` 校验白名单:未知连接器 / 该连接器不允许的键 /
空值一律拒绝入库(AuthdRefusal),绝不静默收下;
- `release()` 按次发放:调用方须给出完整绑定(主机、仓库、
操作),绑定与入库时的作用域不一致即拒绝;
- `consume()` 单次兑现:release 兑现一次即从库里删除,过期
也兑现不了;兑现失败不留可重试的残值。
clock 可注入(测试用假时钟测过期),默认 time.time。"""
def __init__(self, clock: Callable[[], float] | None = None) -> None:
self._store: dict[tuple[str, str], dict] = {}
self._releases: dict[str, SurrogateRelease] = {}
self._clock = clock or time.time
def __repr__(self) -> str: # 绝不含任何凭证值
return "CredentialVault(connectors=%s)" % sorted(
{c for (c, _k) in self._store})
def store(self, connector: str, key: str, value: str,
host: str | None = None, repo: str | None = None) -> None:
"""入库:白名单校验 + 作用域记录(host/repo 是该凭证
允许使用的范围,release 时须一致)。"""
if connector not in ALLOWED_CONNECTORS:
raise AuthdRefusal(
"unknown_connector",
"连接器 %s 不在 authd 白名单内" % connector)
allowed = CONNECTOR_CREDENTIALS.get(connector, frozenset())
if key not in allowed:
raise AuthdRefusal(
"credential_not_allowlisted",
"连接器 %s 不允许凭证键 %s" % (connector, key))
if not isinstance(value, str) or not value.strip():
raise AuthdRefusal(
"empty_credential", "凭证值为空,拒绝入库")
self._store[(connector, key)] = {
"value": value, "host": host, "repo": repo}
def has(self, connector: str, key: str) -> bool:
"""该(连接器,键)是否已入库(不返回值)。"""
return (connector, key) in self._store
def forget(self, connector: str, key: str) -> bool:
"""删一条入库凭证(VM 停止时调用);同时作废其全部未兑现
释放。未知键返回 False,不抛错。"""
gone = self._store.pop((connector, key), None) is not None
if gone:
doomed = [rid for rid, rel in self._releases.items()
if rel.connector == connector
and rel.key == key]
for rid in doomed:
self._releases.pop(rid, None)
return gone
def release(self, connector: str, key: str, host: str, repo: str,
operation: str,
ttl_seconds: int = DEFAULT_RELEASE_TTL_SECONDS,
max_uses: int = DEFAULT_RELEASE_MAX_USES
) -> SurrogateRelease:
"""按次发放:绑定须与入库作用域一致(host/repo 有记录时
须一致,无记录时本次绑定即定作用域),否则拒绝。发放的
释放单次有效、ttl 后过期;发放本身记入库内待兑现表,
须经 consume() 兑现一次才交出值。"""
entry = self._store.get((connector, key))
if entry is None:
raise AuthdRefusal(
"credential_not_stored",
"连接器 %s 的凭证 %s 未入库" % (connector, key))
if entry["host"] is not None and entry["host"] != host:
raise AuthdRefusal(
"scope_mismatch",
"凭证作用域主机不一致,拒绝释放")
if entry["repo"] is not None and entry["repo"] != repo:
raise AuthdRefusal(
"scope_mismatch",
"凭证作用域仓库不一致,拒绝释放")
if operation not in ("clone", "pull", "push"):
raise AuthdRefusal(
"operation_not_allowed",
"操作 %s 不在允许集内" % operation)
release = SurrogateRelease(
release_id=secrets.token_hex(8),
connector=connector, key=key, host=host, repo=repo,
operation=operation, value=entry["value"],
expires_at=self._clock() + ttl_seconds,
max_uses=max_uses)
self._releases[release.release_id] = release
return release
def consume(self, release_id: str) -> str:
"""兑现一次释放:过期/用完/未知 id 一律拒绝(AuthdRefusal,
不含值)。兑现成功返回凭证值,并把该释放从库里删除
(单次有效,用过即废)。"""
release = self._releases.get(release_id)
if release is None:
raise AuthdRefusal(
"release_unknown", "释放记录不存在或已兑现")
if self._clock() >= release.expires_at:
self._releases.pop(release_id, None)
raise AuthdRefusal("release_expired", "释放已过期")
if release.uses_left <= 0:
self._releases.pop(release_id, None)
raise AuthdRefusal("release_exhausted", "释放已用完")
release.uses_left -= 1
value = release.value
self._releases.pop(release_id, None)
return value
def audit_release(self, release: SurrogateRelease) -> dict:
"""发放审计记录(只含绑定,不含值)。"""
record = release.describe()
record["event"] = "authd_credential_released"
return record
def audit_refusal(self, kind: str, connector: str,
key: str | None = None) -> dict:
"""拒绝审计记录(只含 kind 与名,不含值)。"""
return {"event": "authd_credential_refused", "kind": kind,
"connector": connector, "key": key}
def _default_post(url: str, headers: dict, payload: dict):
"""默认 HTTP 客户端(urllib):返回 (status, body_dict)。
只用于 mint_gitea_token,测试注入假 post,不联网。"""
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers=headers,
method="POST")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.status, json.loads(resp.read() or b"{}")
except urllib.error.HTTPError as exc:
try:
body = json.loads(exc.read() or b"{}")
except ValueError:
body = {}
return exc.code, body
def mint_gitea_token(api_url: str, user: str, master_token: str,
name: str, scopes: Iterable[str] = ("write:repository",),
post: Callable[[str, dict, dict], tuple[int, dict]]
| None = None) -> str:
"""经 Gitea API 铸短期专用 token(真替身路径,默认关闭)。
调 `POST {api_url}/users/{user}/tokens`(Gitea 官方 API),
新 token 与 master token 是不同的值、可独立吊销(DELETE 同
路径),到期由调用方吊销。返回新 token 值;失败即
AuthdRefusal(不含任何 token 值)。
前提(见差距表 §3.2):master token 须有建 token 的权限;
现有 write:repository 测试 token 铸 token 会失败(403),
开这条路须另配有铸 token 权限的 token 并做真机验证。
post 可注入(测试用假 post),默认走 urllib。"""
if not api_url or not user or not master_token or not name:
raise AuthdRefusal(
"mint_args_missing", "铸 token 参数不全")
base = api_url.rstrip("/")
url = "%s/users/%s/tokens" % (base, user)
headers = {"Authorization": "token %s" % master_token,
"Content-Type": "application/json"}
payload = {"name": name, "scopes": list(scopes)}
post_fn = post or _default_post
status, body = post_fn(url, headers, payload)
if status not in (200, 201):
raise AuthdRefusal(
"mint_failed",
"Gitea 铸 token 失败:status=%s" % status)
token = (body or {}).get("sha1") or (body or {}).get("token")
if not token:
raise AuthdRefusal(
"mint_empty", "Gitea 铸 token 未返回 token 值")
return token
def revoke_gitea_token(api_url: str, user: str, master_token: str,
token_id: object,
delete: Callable[[str, dict], int] | None = None
) -> bool:
"""吊销一个铸出的 token(`DELETE {api}/users/{user}/tokens/{id}`)。
成功返回 True;404 视为已不在(也返回 True,不报错);
其它失败返回 False(调用方审计,不抛错——吊销失败不能炸任务,
但须留审计)。delete 可注入,默认走 urllib。"""
if not api_url or not user or not master_token \
or token_id is None:
return False
base = api_url.rstrip("/")
url = "%s/users/%s/tokens/%s" % (base, user, token_id)
headers = {"Authorization": "token %s" % master_token}
if delete is None:
def _do_delete(url: str, headers: dict) -> int:
req = urllib.request.Request(url, headers=headers,
method="DELETE")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.status
except urllib.error.HTTPError as exc:
return exc.code
delete = _do_delete
status = delete(url, headers)
return status in (200, 204, 404)
def surrogate_fingerprint(value: str) -> str:
"""凭证值的指纹(审计/日志里唯一允许出现的凭证派生形式):
SHA-256 前 8 位 hex。指纹不可逆推出原值,审计里只记指纹、
不记值。"""
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:8]
def hmac_sign(key: str, message: str) -> str:
"""HMAC-SHA256 签名(释放记录的防篡改绑定,客内守护进程
用它签发 release_id 的校验码;与 Gitea 无关)。"""
return hmac.new(key.encode("utf-8"), message.encode("utf-8"),
hashlib.sha256).hexdigest()
+6
View File
@@ -153,6 +153,12 @@ def _network_fragment(params: dict, env: dict) -> dict:
"egress_proxy": env.get("CHV_EGRESS_PROXY"),
"allow_direct_egress": env.get("CHV_ALLOW_DIRECT_EGRESS")
== "1",
# feat-chv-muse-p3:Sentinel 白名单主机(逗号分隔),
# 默认只放行配置的 Gitea 主机(执行器 wake 预审时追加)。
"sentinel_allow_hosts": tuple(
h.strip().lower()
for h in (env.get("CHV_SENTINEL_ALLOW_HOSTS") or "")
.split(",") if h.strip()),
}
+223 -31
View File
@@ -279,6 +279,7 @@ class CloudHypervisorExecutor(Executor):
disk_gb: float
egress_proxy: str | None
allow_direct_egress: bool
sentinel_allow_hosts: tuple
task_timeout: float | None
snapshot_url: str | None
warm_pool_size: int
@@ -303,6 +304,8 @@ class CloudHypervisorExecutor(Executor):
audit_failure_count: int
_vms: dict
_guest_env_delivered: dict
_authd_delivered: dict
sentinel_resolver: "Callable[..., object] | None"
def __init__(self, binary: str | None = None,
kernel: str | None = None,
@@ -329,7 +332,9 @@ class CloudHypervisorExecutor(Executor):
api_timeout: float | None = None,
ready_timeout: float | None = None,
ready_interval: float | None = None,
host_key_mode: str | None = None) -> None:
host_key_mode: str | None = None,
sentinel_resolver: Callable[..., object] | None = None
) -> None:
self._env: dict = env if env is not None else os.environ # type: ignore[assignment]
# 配置解析已拆到 cloud/chv/config.py(参数优先于环境
# 变量,默认值与 #216 一致;显式空用户/越界端口的收紧
@@ -342,13 +347,15 @@ class CloudHypervisorExecutor(Executor):
"ssh_user": ssh_user, "ssh_port": ssh_port,
"guest_host_py": guest_host_py, "cmdline": cmdline,
"net": net, "net_json": net_json, "ssh_host": ssh_host,
"kvm_path": kvm_path,
"cpuinfo_path": cpuinfo_path, "ch_remote": ch_remote,
"model": model, "api_timeout": api_timeout,
"ready_timeout": ready_timeout,
"kvm_path": kvm_path, "cpuinfo_path": cpuinfo_path,
"ch_remote": ch_remote, "model": model,
"api_timeout": api_timeout, "ready_timeout": ready_timeout,
"ready_interval": ready_interval,
"host_key_mode": host_key_mode}, self._env)
self._copy_config_fields(_cfg)
# feat-chv-muse-p3:Sentinel 解析器可注入(测试用假解析器
# 测 SSRF 挡截;不给时 evaluate 用 socket.getaddrinfo)。
self.sentinel_resolver = sentinel_resolver
self._init_runtime(runner, launcher, api_client, sleeper)
def _init_runtime(self, runner: Callable[..., object] | None,
@@ -367,6 +374,7 @@ class CloudHypervisorExecutor(Executor):
# 名 source 客内文件,未投递的密钥名一律拒绝(防止
# “wake 没投递、执行时却按名 source 一个旧文件”)。
self._guest_env_delivered = {}
self._init_authd_runtime()
# feat-chv-closeup(P2-20):_guest_env_delivered 是
# 跨 wake/build_ssh_command/stop 的共享字典,wake 与
# 执行可并发在不同线程——此前无锁读写,set/get/pop
@@ -396,6 +404,19 @@ class CloudHypervisorExecutor(Executor):
self.launcher = _default_popen_launcher
self._vms = {}
def _init_authd_runtime(self) -> None:
"""feat-chv-muse-p3:authd 投递集与 Sentinel 待审批队列。
_authd_delivered 是本实例已走 authd 通道投递密钥的 VM
名 → 已投递密钥名集合(与 _guest_env_delivered 同锁、
同款语义,唯一区别:authd 投递的密钥名在 build_ssh_command
里不报错、但绝不 source 进 codex 进程环境——它只给客内
authd 守护进程,任务进程里永不出现)。_sentinel_queue
是 Sentinel ask 待审批队列(内存态,进程重启即清;
ask 本来就意味着这次执行已停止)。"""
self._authd_delivered = {}
from cloud.chv.sentinel import ApprovalQueue
self._sentinel_queue = ApprovalQueue()
def _delivered_secrets(self, name: str) -> set:
"""P2-20 唯一读入口:该 VM 已 seed 投递的密钥名集合。"""
with self._guest_env_lock:
@@ -415,6 +436,26 @@ class CloudHypervisorExecutor(Executor):
with self._guest_env_lock:
self._guest_env_delivered.pop(name, None)
def _delivered_authd_secrets(self, name: str) -> set:
"""feat-chv-muse-p3:该 VM 已走 authd 通道投递的密钥名
集合(与 _delivered_secrets 同款读入口)。"""
with self._guest_env_lock:
return set(self._authd_delivered.get(name, set()))
def _mark_authd_delivered(self, name: str, secrets) -> None:
"""feat-chv-muse-p3:记下该 VM 已走 authd 通道投递的
密钥名集合(secrets 为空即清记录,与旧语义一致)。"""
with self._guest_env_lock:
if secrets:
self._authd_delivered[name] = set(secrets)
else:
self._authd_delivered.pop(name, None)
def _clear_authd_delivered(self, name: str) -> None:
"""feat-chv-muse-p3:stop 时清掉该 VM 的 authd 投递记录。"""
with self._guest_env_lock:
self._authd_delivered.pop(name, None)
def _copy_config_fields(self, _cfg) -> None:
"""把 build_chv_config 的字段抄到 self(纯移动,字段集不变)。"""
for _field in (
@@ -431,7 +472,7 @@ class CloudHypervisorExecutor(Executor):
"snapshot_max_total_gb", "snapshot_retention_days",
"snapshot_dir", "allow_remote_snapshot",
"gitea_sync_env", "seed_user_data_extra_file",
"seed_network_config_file"):
"seed_network_config_file", "sentinel_allow_hosts"):
setattr(self, _field, getattr(_cfg, _field))
# -- 名字 / 路径 ---------------------------------------------------
@@ -868,7 +909,8 @@ class CloudHypervisorExecutor(Executor):
def provision_host_key(self, task: dict, secret_env: dict | None = None,
guest_env: dict | None = None,
user_data_extra: str | None = None,
network_config: str | None = None) -> dict:
network_config: str | None = None,
authd_env: dict | None = None) -> dict:
"""按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py):
启动前为该 VM 生成一次性 ed25519 host key 对,公钥
立即写进该 VM 的 known_hosts(钉死),私钥只写进
@@ -887,17 +929,11 @@ class CloudHypervisorExecutor(Executor):
# to10:known_hosts 写入已抽到 _write_provisioned_known_hosts,
# 此处只留生成与 seed 写入的 import(删后不再有未用 import)。
from cloud.chv.hostkeys import (
dirty_marker_path,
generate_host_keypair,
write_seed,
)
vm = self.vm_name(task)
if os.path.exists(dirty_marker_path(self.state_dir, vm)):
raise ChvFailure(
"vm_dirty_refused",
"VM %s 上次清理失败(脏标记存在),拒绝复用旧 "
"host key 启动:请人工清理 state_dir 后删脏标记"
% vm)
self._refuse_dirty_vm(vm)
paths = generate_host_keypair(
self.state_dir, vm,
runner=lambda cmd: self._call_runner(cmd))
@@ -908,13 +944,25 @@ class CloudHypervisorExecutor(Executor):
secret_env=secret_env,
guest_env=guest_env,
user_data_extra=user_data_extra,
network_config=network_config)
network_config=network_config,
authd_env=authd_env)
self._emit("host_key_generated", task,
"vm=%s secret_names=%s"
% (vm, ",".join(sorted(secret_env or {}))))
return {"paths": paths, "seed_dir": seed_dir,
"known_hosts": paths["known_hosts"]}
def _refuse_dirty_vm(self, vm: str) -> None:
"""上次清理失败留下的脏标记存在时直接拒绝(清理失败
即拒起,不再复用旧 key 对)。"""
from cloud.chv.hostkeys import dirty_marker_path
if os.path.exists(dirty_marker_path(self.state_dir, vm)):
raise ChvFailure(
"vm_dirty_refused",
"VM %s 上次清理失败(脏标记存在),拒绝复用旧 "
"host key 启动:请人工清理 state_dir 后删脏标记"
% vm)
def capture_host_key(self, task: dict) -> bool:
"""首次捕获客机 host key(TOFU,弱回落,边界见
ssh_transport.py 与 hostkeys.py):ssh-keyscan 经本机
@@ -974,25 +1022,42 @@ class CloudHypervisorExecutor(Executor):
# 构造兼容,wake/wait 路径仍严格解析。
ssh_host = "127.0.0.1"
secrets, plain = split_secret_env(extra_env)
guest_env_file = None
if secrets:
from cloud.chv.hostkeys import GUEST_ENV_PATH
delivered = self._delivered_secrets(self.vm_name(task))
missing: list = [name for name in secrets if name not in delivered]
if missing:
raise ChvFailure(
"secret_env_not_delivered",
"密钥环境变量 %s 未经 seed 投递,拒绝进 ssh "
"argv:provisioned 模式下 wake 时投递,"
"keyscan 模式不支持密钥投递"
% ",".join(sorted(missing)))
guest_env_file = GUEST_ENV_PATH
guest_env_file = self._resolve_secret_env_file(task, secrets)
return _build_ssh_cmd(
self.ssh_port, self.ssh_key, self.ssh_user,
self.egress_proxy, self.known_hosts_path(task),
inner_cmd, ssh_host=ssh_host, extra_env=plain or None,
guest_env_file=guest_env_file)
def _resolve_secret_env_file(self, task: dict, secrets) -> str | None:
"""feat-chv-muse-p3:密钥分两路查投递,返回远程命令
该 source 的客内密钥文件(无 guest-env 路密钥即 None)。
authd 路的(GITEA_TOKEN)只认“已投递、不报错”,绝不
source 进 codex 进程环境;guest-env 路的才 source
guest-env 文件。两路任一路未投递都 fail-closed(同款 kind)。"""
if not secrets:
return None
from cloud.chv.gitea_sync import split_authd_env
from cloud.chv.hostkeys import GUEST_ENV_PATH
authd_secrets, guest_secrets = split_authd_env(secrets)
name = self.vm_name(task)
missing: list = [
n for n in authd_secrets
if n not in self._delivered_authd_secrets(name)]
missing += [
n for n in guest_secrets
if n not in self._delivered_secrets(name)]
if missing:
raise ChvFailure(
"secret_env_not_delivered",
"密钥环境变量 %s 未经 seed 投递,拒绝进 ssh "
"argv:provisioned 模式下 wake 时投递,"
"keyscan 模式不支持密钥投递"
% ",".join(sorted(missing)))
if guest_secrets:
return GUEST_ENV_PATH
return None
# -- API 模式:VmConfig / API 调用 / 就绪轮询 -----------------------
def _vm_config_disks(self, task_disk: str | None,
seed_disk: str | None) -> list:
@@ -1440,6 +1505,107 @@ class CloudHypervisorExecutor(Executor):
merged.update(sync)
return merged
def _sentinel_gate_gitea(self, task: dict,
extra_env: dict | None) -> None:
"""feat-chv-muse-p3:wake 时的 Gitea 出口 Sentinel 预审。
同步已请求(extra_env 里有 GITEA_URL)时才评估;无同步
变量即跳过(本次启动不同步,无出口可审)。评估目标:
GET https://<Gitea 主机>/api/v1/version。
- allow → 审计 sentinel_egress_allow,通过;
- deny → 审计 + ChvFailure(sentinel_egress_denied),
起 VMM 前 fail-closed;
- ask → 记入待审批队列 + 审计 sentinel_egress_ask,
同样 fail-closed:wake 等不了审批 UI,ask 在这里就是
deny,须运维把主机加进 CHV_SENTINEL_ALLOW_HOSTS 再起。
白名单来源:CHV_SENTINEL_ALLOW_HOSTS + 运维配置的 Gitea
主机(CHV_GITEA_URL 的主机,运维可信);任务级覆盖改了
GITEA_URL 主机时不自动放行——改到未知主机就是 ask。
SSRF 检查对白名单主机同样生效(DNS 可被投毒,白名单主机
解析到私网 IP 仍 deny)。审计 detail 只含 VM 名/主机/
verdict,不含任何凭证值。"""
sync_url = (extra_env or {}).get("GITEA_URL")
if not sync_url:
return
name = self.vm_name(task)
host, scheme, port = self._gate_gitea_target(task, name, sync_url)
self._gate_gitea_apply(task, name, host, scheme, port)
def _gate_gitea_target(self, task: dict, name: str,
sync_url: str) -> tuple:
"""解析 Gitea 出口预审目标(主机/协议/端口);主机解析
不出即审计 + fail-closed。"""
from urllib.parse import urlparse
parsed = urlparse(sync_url)
host = (parsed.hostname or "").lower()
scheme = (parsed.scheme or "").lower()
port = parsed.port or (443 if scheme == "https" else 80)
if not host:
self._emit("sentinel_egress_denied", task,
"vm=%s gitea_url_invalid" % name)
raise ChvFailure(
"sentinel_egress_denied",
"Gitea URL 主机解析不出,拒绝启动")
return host, scheme, port
def _gate_gitea_policy(self, host: str):
"""预审策略:白名单来源 CHV_SENTINEL_ALLOW_HOSTS +
运维配置的 Gitea 主机(CHV_GITEA_URL 的主机,运维可信);
任务级覆盖改了 GITEA_URL 主机时不自动放行——改到未知
主机就是 ask。"""
from urllib.parse import urlparse
from cloud.chv.sentinel import SentinelPolicy
cfg_sync = getattr(self, "gitea_sync_env", None) or {}
cfg_host = ""
cfg_url = cfg_sync.get("GITEA_URL")
if cfg_url:
cfg_host = (urlparse(cfg_url).hostname or "").lower()
extra_allow = {host} if host and host == cfg_host else set()
return SentinelPolicy.from_env(
self._env, extra_allow_hosts=extra_allow)
def _gate_gitea_apply(self, task: dict, name: str, host: str,
scheme: str, port: int) -> None:
"""对预审目标做 Sentinel 评估并按 verdict 处理:
allow → 审计通过;ask → 记入待审批队列 + 审计,同样
fail-closed(wake 等不了审批 UI,ask 在这里就是 deny,
须运维把主机加进 CHV_SENTINEL_ALLOW_HOSTS 再起);
deny → 审计 + ChvFailure,起 VMM 前 fail-closed。
SSRF 检查对白名单主机同样生效(DNS 可被投毒,白名单
主机解析到私网 IP 仍 deny)。审计 detail 只含 VM 名/
主机/verdict,不含任何凭证值。"""
from cloud.chv.sentinel import EgressRequest
policy = self._gate_gitea_policy(host)
request = EgressRequest("gitea", "GET", scheme, host, port,
"/api/v1/version")
decision = policy.evaluate(
request, resolve=getattr(self, "sentinel_resolver", None))
if decision.verdict == "allow":
capability = decision.capability
assert capability is not None # allow 恒带严格能力
self._emit("sentinel_egress_allow", task,
"vm=%s host=%s capability=%s"
% (name, host, capability.id))
return
if decision.verdict == "ask":
pending_id = self._sentinel_queue.record(
request, decision.reasons)
self._emit("sentinel_egress_ask", task,
"vm=%s host=%s pending=%s"
% (name, host, pending_id))
raise ChvFailure(
"sentinel_egress_denied",
"Gitea 主机 %s 不在 Sentinel 白名单内(待审批 %s),"
"拒绝启动:请运维加进 CHV_SENTINEL_ALLOW_HOSTS"
% (host, pending_id))
self._emit("sentinel_egress_denied", task,
"vm=%s host=%s reasons=%s"
% (name, host, ",".join(decision.reasons)))
raise ChvFailure(
"sentinel_egress_denied",
"Gitea 出口被 Sentinel 拒绝(%s),拒绝启动"
% ",".join(decision.reasons))
def _wake_prechecks(self, task: dict, name: str, extra_env) -> tuple:
"""wake 前置:preflight/容量/配额/net/密钥模式,纯移动。
失败时发 vm_failed 审计并原样抛 ChvFailure。"""
@@ -1471,6 +1637,9 @@ class CloudHypervisorExecutor(Executor):
"provisioned 模式支持(当前 %s)"
% (",".join(sorted(secrets)),
getattr(self, "host_key_mode", "provisioned")))
# feat-chv-muse-p3:Gitea 出口 Sentinel 预审(起 VMM
# 前 fail-closed,见 _sentinel_gate_gitea)。
self._sentinel_gate_gitea(task, extra_env)
except ChvFailure as exc:
self._emit("vm_failed", task,
"vm=%s wake 前置失败 kind=%s" % (name, exc.kind),
@@ -1478,6 +1647,20 @@ class CloudHypervisorExecutor(Executor):
raise
return secrets, net_entries
def _split_seed_authd_env(self, secrets,
guest_env: dict | None) -> tuple:
"""feat-chv-muse-p3:seed 密钥集先拆通道——GITEA_TOKEN
走 authd(独立的 authd-seed 文件,只给客内 authd 读),
其余密钥仍走 guest-env。guest_env(split_guest_env 拼
出来的)里也含 GITEA_TOKEN:一并拆出来(与 secrets 拆
出的合并,secrets 的值优先),否则 write_seed 会拒收。
返回 (authd_env, guest_secrets, guest_env)。"""
from cloud.chv.gitea_sync import split_authd_env
authd_env, guest_secrets = split_authd_env(secrets)
dup_authd, guest_env = split_authd_env(guest_env)
dup_authd.update(authd_env)
return dup_authd, guest_secrets, guest_env
def _provision_seed_disk(self, task: dict, name: str, secrets,
guest_env: dict | None = None,
seed_extras: tuple | None = None) -> str | None:
@@ -1503,16 +1686,24 @@ class CloudHypervisorExecutor(Executor):
seed_disk_path,
)
extra, netcfg = seed_extras or (None, None)
authd_env, guest_secrets, guest_env = \
self._split_seed_authd_env(secrets, guest_env)
provisioned = self.provision_host_key(
task, secret_env=secrets or None,
task, secret_env=guest_secrets or None,
guest_env=guest_env or None,
user_data_extra=extra, network_config=netcfg)
user_data_extra=extra, network_config=netcfg,
authd_env=authd_env or None)
seed_disk = seed_disk_path(self.state_dir, name)
build_seed_disk(
provisioned["seed_dir"], seed_disk,
runner=lambda cmd: self._call_runner(cmd))
delete_host_private_key(self.state_dir, name)
self._mark_guest_env_delivered(name, secrets)
# 两路投递记录分开记:guest-env 路的进
# _guest_env_delivered(任务期可 source),authd 路的进
# _authd_delivered(任务期只认“已投递、不报错”,绝不
# source 进 codex 环境)。
self._mark_guest_env_delivered(name, guest_secrets)
self._mark_authd_delivered(name, authd_env)
return seed_disk
def _boot_vm(self, task, name, task_disk, seed_disk,
@@ -1869,6 +2060,7 @@ class CloudHypervisorExecutor(Executor):
self._emit("vm_net_registry_delete_failed", task,
"vm=%s error=%s" % (name, _net_exc))
self._clear_guest_env_delivered(name)
self._clear_authd_delivered(name)
return failed
def _write_dirty_marker(self, name, failed):
+20
View File
@@ -43,6 +43,26 @@ GITEA_SYNC_KEYS = frozenset({
# _CHV_SECRET_ENV_NAMES)。
GITEA_SYNC_SECRET_KEYS = frozenset({"GITEA_TOKEN"})
# 其中 authd 专用密钥(feat-chv-muse-p3):只进客内 authd 的
# 独立 seed 文件 /etc/chv/authd-seed(见 hostkeys.AUTHD_SEED_PATH),
# 不进 guest-env、不进 codex 进程环境。本期只有 GITEA_TOKEN。
AUTHD_SECRET_KEYS = frozenset({"GITEA_TOKEN"})
def split_authd_env(secrets: dict | None) -> tuple[dict, dict]:
"""把密钥集拆成(走 authd 通道的子集,仍走 guest-env 的子集)。
authd 子集 = AUTHD_SECRET_KEYS 命中的键(本期只有 GITEA_TOKEN);
其余密钥(如 SUB2API_API_KEY)仍走 guest-env(见差距表 §3.3,
网关 key 的 privsep 改造是后续工作)。"""
authd, rest = {}, {}
for key, value in dict(secrets or {}).items():
if key in AUTHD_SECRET_KEYS:
authd[key] = value
else:
rest[key] = value
return authd, rest
# 其中非密钥、但 boot 时就要用、也必须进 guest-env 的键
#(SSH env 前缀到不了 cloud-init 时运行的 chv-sync boot)。
GITEA_SYNC_PLAIN_KEYS = GITEA_SYNC_KEYS - GITEA_SYNC_SECRET_KEYS
+103 -24
View File
@@ -42,6 +42,17 @@ from cloud.chv.atomic_file import atomic_write_text
# 除 seed 目录/user-data(随 VM 销毁而删)外不另存该文件。
GUEST_ENV_PATH = "/etc/chv/guest-env"
# seed 投递的客内 authd 专用密钥文件路径(feat-chv-muse-p3):
# 只写 AUTHD_SECRET_KEYS 命中的密钥(本期只有 GITEA_TOKEN),
# 0600 root,只给客内 authd 守护进程读;codex 进程环境里永不
# 出现这些键(见执行器 _authd_delivered)。与 guest-env 分离
# 的理由:guest-env 会被远程命令 source 进任务进程环境,任何
# 进 guest-env 的密钥都会进 codex 环境;authd 密钥绝不能走
# 那条路。文件本身仍是 seed 写出的落盘文件(cloud-init 的唯一
# 投递通道就是写文件),真正让它对 agent 不可见的是 P2 的
# nspawn 单元隔离,见差距表 §3.1。
AUTHD_SEED_PATH = "/etc/chv/authd-seed"
def host_key_paths(state_dir: str, vm_name: str) -> dict:
"""该 VM 的 host key 文件路径(私钥/公钥/known_hosts)。"""
@@ -180,6 +191,24 @@ def build_seed_user_data(private_key_text: str, public_key_text: str) -> str:
return "\n".join(lines) + "\n"
def _authd_env_entry(authd_env: dict) -> list:
"""authd-seed 的 write_files 条目行(0600 root,值经
shlex.quote 引用):只装 AUTHD_SECRET_KEYS 命中的密钥,
与 guest-env 条目并列写进同一 write_files 列表(绝不另写
一个 write_files: 键,见 _guest_env_entry 的 YAML 重复键
禁令)。"""
lines = [
" - path: %s" % AUTHD_SEED_PATH,
" permissions: '0600'",
" owner: root:root",
" content: |",
]
for key in sorted(authd_env):
lines.append(" %s=%s"
% (key, shlex.quote(str(authd_env[key]))))
return lines
def _guest_env_entry(merged_env: dict) -> list:
"""guest-env 的 write_files 条目行(0600 root,值经
shlex.quote 引用):只能并进已有的 write_files 列表,
@@ -227,12 +256,68 @@ def _apply_seed_extras(seed_dir: str, user_data: str,
return user_data
def _validate_authd_env(authd_env: dict | None) -> None:
"""authd_env 的键必须全在 AUTHD_SECRET_KEYS 内(本期只有
GITEA_TOKEN),否则 ValueError——authd 通道只装 authd
密钥,别的密钥误进这里会绕过 guest-env 的 source 语义。"""
if not authd_env:
return
from cloud.chv.gitea_sync import AUTHD_SECRET_KEYS
unknown = sorted(set(authd_env) - AUTHD_SECRET_KEYS)
if unknown:
raise ValueError(
"authd 通道只接受 %s,拒绝:%s"
% (",".join(sorted(AUTHD_SECRET_KEYS)),
",".join(unknown)))
def _write_seed_meta(seed_dir: str, vm_name: str) -> str:
"""建 seed 目录并写 meta-data(0600),返回 user-data 路径。
feat-chv-cover100:seed 暂存文件走 atomic_file(mkstemp
0600 → 写 → fsync → rename):user-data 含 host 私钥
明文,直接写 + 事后 chmod 会在 chmod 前以 umask 权限
短暂可读;半截文件到不了 ISO(见 H5),原子写同时堵
住权限窗口与半截残留。"""
os.makedirs(seed_dir, exist_ok=True)
meta_path = os.path.join(seed_dir, "meta-data")
user_path = os.path.join(seed_dir, "user-data")
atomic_write_text(
meta_path,
"instance-id: %s\nlocal-hostname: %s\n" % (vm_name, vm_name))
return user_path
def _seed_env_entries(merged_env: dict,
authd_env: dict | None) -> list:
"""guest-env 与 authd-seed 两路的 write_files 条目行
(皆空即空列表,不写条目)。"""
extra_lines = []
if merged_env:
extra_lines += _guest_env_entry(merged_env)
if authd_env:
extra_lines += _authd_env_entry(authd_env)
return extra_lines
def _insert_seed_entries(user_data: str, lines: list) -> str:
"""条目插在 runcmd 段之前、仍属同一 write_files 列表
(seed 自 2026-11-07 起不再带 runcmd,见
build_seed_user_data 的根因禁令;marker 分支只作
防御,将来模板若恢复 runcmd 仍插在其前)。"""
marker = "runcmd:\n"
if marker in user_data:
head, tail = user_data.split(marker, 1)
return head + "\n".join(lines) + "\n" + marker + tail
return user_data + "\n".join(lines) + "\n"
def write_seed(seed_dir: str, vm_name: str, private_key_text: str,
public_key_text: str,
secret_env: dict | None = None,
guest_env: dict | None = None,
user_data_extra: str | None = None,
network_config: str | None = None) -> str:
network_config: str | None = None,
authd_env: dict | None = None) -> str:
"""写 seed 目录:meta-data + user-data(均为 0600),
返回 seed 目录路径。调用方负责随 VM 销毁删掉它。
@@ -243,34 +328,28 @@ def write_seed(seed_dir: str, vm_name: str, private_key_text: str,
装 boot 时要用的非密钥同步变量(GITEA_URL 等,见
gitea_sync):只走 SSH 前缀到不了 cloud-init 时运行
的 chv-sync boot。两者皆空时不写该条目。
authd_env(feat-chv-muse-p3)给定时写进另一个独立条目
AUTHD_SEED_PATH(0600 root,只给客内 authd 守护进程读):
键必须全在 gitea_sync.AUTHD_SECRET_KEYS 内(本期只有
GITEA_TOKEN),否则 ValueError——authd 通道只装 authd
密钥,别的密钥误进这里会绕过 guest-env 的 source 语义。
user_data_extra / network_config 见 _apply_seed_extras
(客机用户与静态网络的供给,皆空即不写)。"""
os.makedirs(seed_dir, exist_ok=True)
meta_path = os.path.join(seed_dir, "meta-data")
user_path = os.path.join(seed_dir, "user-data")
# feat-chv-cover100:seed 暂存文件走 atomic_file(mkstemp
# 0600 → 写 → fsync → rename):user-data 含 host 私钥
# 明文,直接写 + 事后 chmod 会在 chmod 前以 umask 权限
# 短暂可读;半截文件到不了 ISO(见 H5),原子写同时堵
# 住权限窗口与半截残留。
atomic_write_text(
meta_path,
"instance-id: %s\nlocal-hostname: %s\n" % (vm_name, vm_name))
_validate_authd_env(authd_env)
user_path = _write_seed_meta(seed_dir, vm_name)
user_data = build_seed_user_data(private_key_text, public_key_text)
merged_env = dict(guest_env or {})
merged_env.update(secret_env or {})
if merged_env:
lines = _guest_env_entry(merged_env)
# 条目插在 runcmd 段之前、仍属同一 write_files 列表
#(seed 自 2026-11-07 起不再带 runcmd,见
# build_seed_user_data 的根因禁令;marker 分支只作
# 防御,将来模板若恢复 runcmd 仍插在其前)。
marker = "runcmd:\n"
if marker in user_data:
head, tail = user_data.split(marker, 1)
user_data = head + "\n".join(lines) + "\n" + marker + tail
else:
user_data = user_data + "\n".join(lines) + "\n"
# feat-chv-muse-p3:GITEA_TOKEN 绝不进 guest-env——它只能走
# authd_env(独立的 authd-seed 条目)。调用方(执行器)须先
# 用 gitea_sync.split_authd_env() 拆出来;这里再拦一次,
# 免得别的调用点把旧写法带进来、悄悄恢复泄露面。
if "GITEA_TOKEN" in merged_env:
raise ValueError(
"GITEA_TOKEN 须走 authd_env,不进 guest-env")
extra_lines = _seed_env_entries(merged_env, authd_env)
if extra_lines:
user_data = _insert_seed_entries(user_data, extra_lines)
user_data = _apply_seed_extras(seed_dir, user_data,
user_data_extra, network_config)
atomic_write_text(user_path, user_data)
+342
View File
@@ -0,0 +1,342 @@
"""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 只含网段名与策略名。
"""
import ipaddress
import socket
import time
import uuid
from collections.abc import Callable, Iterable
# 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、登录页、
# 任意管理接口)一律不在本连接器的能力内。
_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"),
),
}
# 每连接器允许的 scheme/端口(默认:只 https/443;http 只在
# 显式放行时才出现,本期没有任何连接器放 http)。
_CONNECTOR_SCHEMES = {"gitea": frozenset({"https"})}
_CONNECTOR_PORTS = {"gitea": 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)
self.expires_at = (now if now is not None else time.time()) \
+ ttl_seconds
self.max_uses = max_uses
self.uses_left = 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 at >= self.expires_at:
return False
if 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
self.uses_left -= 1
return True
class Decision:
"""一次审批决策:allow / deny / ask + 原因 + 能力(仅 allow
时有)+ 审计记录(无值)。"""
def __init__(self, verdict: str, reasons: Iterable[str],
capability: Capability | None = None) -> None:
assert verdict in ("allow", "deny", "ask")
self.verdict = verdict
self.reasons = list(reasons)
self.capability = capability
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.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
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) -> Decision:
"""评估一次出口请求(按顺序,首个命中即定 verdict):
1. 未知连接器 → deny(没有策略的连接器不放行);
2. scheme 不在该连接器允许集 → deny;
3. 端口不在该连接器允许集 → deny;
4. 主机在拒绝名单 → deny;
5. 解析失败 → deny(fail-closed);
6. 任一解析 IP 落在挡截网段 → deny(SSRF);
7. 主机不在白名单 → ask(记入待审批队列,调用方停止执行);
8. 方法/路径不在连接器 L7 规则内 → deny;
9. 全部通过 → allow + 严格能力(单次、300 秒)。
"""
rules = self.connector_paths.get(request.connector)
if rules is None:
return Decision("deny", ["unknown_connector"])
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 self.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])
if request.host not in self.allow_hosts:
return Decision("ask", ["host_not_allowlisted"])
if not _path_allowed(request, rules):
return Decision("deny", ["path_not_allowed"])
capability = Capability(
request.connector, request.host,
{request.method}, rules,
ttl_seconds=DEFAULT_CAPABILITY_TTL_SECONDS,
max_uses=1, now=now)
return Decision("allow", ["policy_allow"],
capability=capability)
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