对齐 Muse P4:浏览器完全对齐(CDP broker 单元外 + a11y-tree-only + 确定性分类器)

- chv-guest/chv-cdp-broker.py(新):单元外 CDP broker,用户 chv-browser,
  独家 chromium.launch(headless=True, args=LAUNCH_ARGS);启动参数白名单
  无 --remote-debugging-port、无 devtools;导航前 URL 校验 + 拦截名单;
  快照恒带 untrusted_input 与 injection_flags;高风险表单在页时 open
  即 ask 入队;观测到导航 POST 自动暂停;命中拦截规则的下载取消 + 暂停
  + ask;全部回包与错误经 sanitize_error + response_leaks 检查。
- chv-guest/chv-browser.py(P2 版重构):改为 broker 客户端,不再直连
  Playwright(本文件无 playwright import),保留 URL 校验 + 快照截断
  作为第二层防线;暂停状态与 broker 同步。
- chv-guest/chv_browser_guard.py(新):纯 stdlib 共享规则——validate_url、
  拦截名单加载/匹配、快照截断、错误消毒(去 HTML、截断 200 字符、凭据形
  key=value 与 sk- 形 token 打码)、注入启发式(中英文各 6 条)、表单风险
  扫描、下载扫描(可执行扩展名)、文件型 ask 队列(pending/allowed/denied,
  与 P3 ApprovalQueue 同语义)、启动参数与回包泄露检查、脚本注入面
  禁止清单(FORBIDDEN_PAGE_ATTRS/FORBIDDEN_OPS)。
- chv-guest/browser-blocklist.txt(新):静态拦截名单;首版只有 RFC 2606
  .test 自测条目,生产条目由运维构建时填入,无在线更新。
- chv-guest/build.sh/buildinfo.py:P2 三脚本 + P4 三文件纳入镜像与
  buildinfo;新增 chv-cdp-broker.service 与 /run/chv/ask-queue tmpfiles。
- docs/chv-muse-p4-gap.md(新):现状 vs Muse 官方要求差距表、改动、
  诚实边界、P2 合入约定、真机待验证清单(round 12)。
- docs/chv-production-readiness.md:补 §15(P4 一节;编号合入时若与
  P2 的 §15 撞,顺延)。
- cloud/tests/test_chv_muse_p4.py(新,36 项):guard 单元、AST 钉住
  禁止属性、假 Playwright broker 测试、假 broker agent 测试、真 Unix
  socket 联调、buildinfo/build 干跑断言;含否定测试(evaluate 被拒、
  DOM 外泄被拒、DevTools 不可达、私有地址被拒、高风险表单被拦、
  注入文本被标注)。

测试:P4 新测试 36/36;全量 cloud 套件 1903 项,两次连续全过
(skipped=8)。附记:第一次全量跑出 1 个失败但输出未保留、后两次
均未复现,疑为时序抖动,未追认。
真机验证未做:排队等 round-11/第三棒释放 100.65.51.33,清单见 gap
表 §5。本地提交,未推未合未部署。
This commit is contained in:
host committed 2026-11-06 22:36:56 +00:00
1 parent 06c3e172fe
commit 1c8fc3f7c8
11 files changed
+2878 -9

No files matched your search

+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""chv-broker:单元外 IPC broker(客机侧、单元外服务)。
对齐 Muse P2(见 docs/chv-muse-p2-gap.md):单元内外走 Unix socket +
SO_PEERCRED 的最小权限 IPC。broker 只认白名单操作,凭证与浏览器
都在单元外——单元看不见 token,只能请 broker 代办。
校验(双重,缺一不可才放行;cell.pid 不存在时只做 uid 检查并打日志):
1. SO_PEERCRED 的 uid 须在单元映射范围 [1000, 1000+65536) 内;
2. 对端 pid 的 pid ns 须与 /run/chv/cell.pid 记的单元 init 同 ns
(读不到 cell.pid 时跳过第 2 项并警告;cell.pid 存在但对端 ns
读不到时拒绝,不降级)。
操作白名单:
ping -> {"ok": true}
sync {action: pull|push|status, repo?} -> 单元外以 chv 身份跑
chv-sync(list argv,不进 shell;仓目录只允许
~/workspace 下,realpath + commonpath 校验)。
browser {…} -> 原样转发给 chv-browser.sock(浏览器子代理),
回包原样返回;浏览器服务不在时报错,不替它跑 playwright。
只用标准库。单连接一请求一响应(JSON 行,≤64KB);异常只断本
连接,绝不杀死 broker 循环。
"""
import grp
import json
import os
import socket
import struct
import subprocess
import sys
BROKER_SOCK = "/run/chv/broker.sock"
BROWSER_SOCK = "/run/chv/browser.sock"
CELL_PID_FILE = "/run/chv/cell.pid"
UID_BASE = 1000
UID_COUNT = 65536
WORKSPACE = "/home/chv/workspace"
SYNC_USER = "chv"
SYNC_BIN = "/usr/local/bin/chv-sync"
MAX_REQUEST_BYTES = 65536
MAX_OUTPUT_BYTES = 8192
SYNC_ACTIONS = ("pull", "push", "status")
def _read_ns_link(pid, proc_root="/proc"):
"""读 /proc/<pid>/ns/pid 的链接目标;读不到返回 None。"""
try:
return os.readlink(os.path.join(proc_root, str(pid), "ns", "pid"))
except OSError:
return None
def peer_allowed(peer_uid, peer_pid, cell_pid=None, proc_root="/proc"):
"""对端是否允许用 broker。返回 (allowed, reason)。
cell_pid 为 None 时只做 uid 检查(broker 打日志警告,调用方负责)。"""
if not (UID_BASE <= peer_uid < UID_BASE + UID_COUNT):
return False, "uid %d 不在单元映射范围 [%d, %d)" % (
peer_uid, UID_BASE, UID_BASE + UID_COUNT)
if cell_pid is None:
return True, "uid 在范围内(cell.pid 缺失,未做 pid ns 对照)"
cell_ns = _read_ns_link(cell_pid, proc_root)
if cell_ns is None:
return False, "cell.pid 的 pid ns 读不到,拒绝(不降级)"
peer_ns = _read_ns_link(peer_pid, proc_root)
if peer_ns is None:
return False, "对端 pid ns 读不到,拒绝"
if peer_ns != cell_ns:
return False, "对端 pid ns 与单元不一致"
return True, "ok"
def _read_cell_pid():
try:
with open(CELL_PID_FILE) as fh:
return int(fh.read().strip())
except (OSError, ValueError):
return None
def _resolve_repo(repo):
"""仓目录只允许 ~/workspace 下。repo 为空时返回 None(由
chv-sync 自己取唯一仓)。返回解析后的绝对路径或抛 ValueError。"""
if not repo:
return None
base = os.path.realpath(WORKSPACE)
target = os.path.realpath(repo)
if os.path.commonpath([base, target]) != base or target == base:
raise ValueError("仓目录只允许 %s 下:%s" % (WORKSPACE, repo))
return target
def _truncate(text, limit=MAX_OUTPUT_BYTES):
if text is None:
return ""
if len(text) > limit:
return text[:limit] + "\n…(已截断)"
return text
def _run_sync(action, repo):
if action not in SYNC_ACTIONS:
return {"ok": False,
"error": "sync action 只允许 %s:%s"
% ("/".join(SYNC_ACTIONS), action)}
try:
repo_path = _resolve_repo(repo)
except ValueError as e:
return {"ok": False, "error": str(e)}
cmd = ["runuser", "-u", SYNC_USER, "--", "env", "HOME=/home/chv",
SYNC_BIN, action]
if repo_path:
cmd.append(repo_path)
try:
proc = subprocess.run(cmd, capture_output=True, text=True,
timeout=300)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "chv-sync 超时(300 秒)"}
except OSError as e:
return {"ok": False, "error": "chv-sync 起不来:%s" % e}
return {"ok": proc.returncode == 0,
"stdout": _truncate(proc.stdout),
"stderr": _truncate(proc.stderr),
"returncode": proc.returncode}
def _forward_browser(req, sock_path=BROWSER_SOCK, timeout=120):
"""转发给浏览器子代理;回包须是带 ok 的 dict,否则报错。"""
try:
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
conn.settimeout(timeout)
conn.connect(sock_path)
except OSError as e:
return {"ok": False,
"error": "浏览器子代理未就绪:%s" % e}
try:
conn.sendall((json.dumps(req, ensure_ascii=False) + "\n").encode())
data = b""
while len(data) < MAX_REQUEST_BYTES:
chunk = conn.recv(4096)
if not chunk:
break
data += chunk
if data.endswith(b"\n"):
break
except OSError as e:
return {"ok": False, "error": "浏览器子代理通信失败:%s" % e}
finally:
conn.close()
try:
resp = json.loads(data.decode())
except (ValueError, UnicodeDecodeError):
return {"ok": False, "error": "浏览器子代理回包不是 JSON"}
if not isinstance(resp, dict) or "ok" not in resp:
return {"ok": False, "error": "浏览器子代理回包格式不对"}
return resp
def handle_request(req, browser_sock=BROWSER_SOCK):
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。"""
if not isinstance(req, dict):
return {"ok": False, "error": "请求须是 JSON 对象"}
op = req.get("op")
if op == "ping":
return {"ok": True, "cell": True}
if op == "sync":
return _run_sync(req.get("action"), req.get("repo"))
if op == "browser":
return _forward_browser(req, sock_path=browser_sock)
return {"ok": False, "error": "未知操作:%r(白名单:ping/sync/browser)"
% (op,)}
def _peer_creds(conn):
"""SO_PEERCRED:返回 (pid, uid, gid)。Linux struct ucred = 3 个 int。"""
raw = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED,
struct.calcsize("3i"))
pid, uid, gid = struct.unpack("3i", raw)
return pid, uid, gid
def serve(sock_path=BROKER_SOCK, browser_sock=BROWSER_SOCK):
"""监听 Unix socket,逐连接处理。返回码 0 只能是被信号杀死;
socket 建不起来即抛错(systemd 会重启服务,不要静默带病跑)。"""
if os.path.exists(sock_path):
os.remove(sock_path)
os.makedirs(os.path.dirname(sock_path), exist_ok=True)
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
srv.bind(sock_path)
srv.listen(16)
# 0660 + 组 chv:单元内 root 在客机侧是 uid 1000(chv),
# 组 chv 可连;其它用户连不上(再叠加 SO_PEERCRED 校验)。
os.chmod(sock_path, 0o660)
try:
gid = grp.getgrnam("chv").gr_gid
except KeyError:
print("chv-broker: 组 chv 不存在,socket 权限置不全,拒绝启动",
file=sys.stderr)
raise SystemExit(1)
os.chown(sock_path, -1, gid)
print("chv-broker: 监听 %s" % sock_path, file=sys.stderr, flush=True)
while True:
try:
conn, _ = srv.accept()
except OSError:
continue
try:
pid, uid, _gid = _peer_creds(conn)
cell_pid = _read_cell_pid()
allowed, reason = peer_allowed(uid, pid, cell_pid)
if not allowed:
print("chv-broker: 拒绝连接 pid=%d uid=%d:%s"
% (pid, uid, reason), file=sys.stderr, flush=True)
conn.close()
continue
if cell_pid is None:
print("chv-broker: 警告:cell.pid 缺失,只做了 uid 检查 "
"(pid=%d uid=%d)" % (pid, uid), file=sys.stderr,
flush=True)
conn.settimeout(310)
data = b""
while len(data) < MAX_REQUEST_BYTES:
chunk = conn.recv(4096)
if not chunk:
break
data += chunk
if data.endswith(b"\n"):
break
try:
req = json.loads(data.decode())
except (ValueError, UnicodeDecodeError):
resp = {"ok": False, "error": "请求不是合法 JSON"}
else:
resp = handle_request(req, browser_sock=browser_sock)
conn.sendall((json.dumps(resp, ensure_ascii=False)
+ "\n").encode())
except OSError as e:
print("chv-broker: 连接处理异常:%s" % e, file=sys.stderr,
flush=True)
finally:
try:
conn.close()
except OSError:
pass
def main(argv):
sock_path = BROKER_SOCK
browser_sock = BROWSER_SOCK
i = 0
while i < len(argv):
if argv[i] == "--socket" and i + 1 < len(argv):
sock_path = argv[i + 1]
i += 2
elif argv[i] == "--browser-socket" and i + 1 < len(argv):
browser_sock = argv[i + 1]
i += 2
else:
print("chv-broker: 未知参数:%s" % argv[i], file=sys.stderr)
return 2
serve(sock_path, browser_sock)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))