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

+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)