对齐 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

+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""chv-browser:浏览器子代理(客机侧、单元外独立进程)。
对齐 Muse P4(见 docs/chv-muse-p4-gap.md):本文件源自 P2 的
chv-browser.py(c61484a),P4 在此基础上重构——子代理不再直连
Playwright/Chromium,Chromium 的所有权在单元外 CDP broker
(chv-cdp-broker.py)手里;本进程只是 broker 的客户端。
子代理只向单元返回页面无障碍树快照(Playwright
accessibility.snapshot,经 broker 取回),不见原始 DOM,不能
在页面上下文跑 JS,不暴露交互操作,不开 DevTools。
操作(Unix socket /run/chv/browser.sock,JSON 行):
open {url} -> 经 broker 导航并返回 {title, url, a11y,
truncated, untrusted_input, injection_flags,
form_risk}
snapshot {} -> 当前页同上
screenshot {} -> 截图落盘,返回 {path}
close {} -> 关当前页
pause {} -> 暂停(用户接管/填表/自动暂停的统一状态)
resume {} -> 解除暂停
status {} -> {paused, url, form_risk}
list_asks {} -> 待审批 ask 列表
resolve_ask {ask_id, decision} -> 写回 allow/denied
ping {} -> {"ok": true}
暂停时除 ping/resume/status/list_asks/resolve_ask 外一切操作
拒绝——用户接管或凭证注入填表时,代理完全不能行动。
URL 校验、快照截断、错误消毒、禁用操作表都在
chv_browser_guard.py(纯函数模块,两脚本共用)。本层再做一次
validate_url 与 truncate_a11y(纵深防御),回包先过泄露检查。
只用标准库。运行身份:系统用户 chv-browser,socket 0600
(broker 以 root 连,不受模式限制)。
"""
import importlib.util
import json
import os
import socket
import sys
BROWSER_SOCK = "/run/chv/browser.sock"
CDP_SOCK = "/run/chv/cdp-broker.sock"
MAX_REQUEST_BYTES = 65536
_GUARD = None
def _load_guard():
"""加载同目录的 chv_browser_guard(纯函数模块)。"""
global _GUARD
if _GUARD is not None:
return _GUARD
here = os.path.dirname(os.path.abspath(__file__))
path = os.path.join(here, "chv_browser_guard.py")
spec = importlib.util.spec_from_file_location(
"chv_browser_guard", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
_GUARD = module
return module
class BrowserAgent:
"""浏览器子代理:CDP broker 的客户端。broker_call 供单测注入
假 broker(收到 req dict 返回 resp dict);正常运行时走
Unix socket 直连 broker。"""
def __init__(self, cdp_sock_path=CDP_SOCK, broker_call=None):
self._guard = _load_guard()
self._cdp_sock_path = cdp_sock_path
self._broker_call = broker_call
self._paused = False
def _call(self, req):
if self._broker_call is not None:
return self._broker_call(req)
try:
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
conn.settimeout(120)
conn.connect(self._cdp_sock_path)
except OSError as e:
return {"ok": False,
"error": "CDP broker 未就绪:%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": "CDP broker 通信失败:%s" % e}
finally:
conn.close()
try:
resp = json.loads(data.decode())
except (ValueError, UnicodeDecodeError):
return {"ok": False, "error": "CDP broker 回包不是 JSON"}
if not isinstance(resp, dict) or "ok" not in resp:
return {"ok": False, "error": "CDP broker 回包格式不对"}
return resp
def _sync_paused(self, resp):
"""从回包同步暂停镜像状态(broker 暂停时本层同样拒绝)。"""
if isinstance(resp, dict) and "已暂停" in str(
resp.get("error", "")):
self._paused = True
return resp
def _blocked_while_paused(self):
return {"ok": False,
"error": "浏览器已暂停(用户接管/填表/自动暂停)"}
def open(self, url):
if self._paused:
return self._blocked_while_paused()
ok, err = self._guard.validate_url(url)
if not ok:
return {"ok": False, "error": err}
resp = self._call({"op": "open", "url": url})
if resp.get("ok") and isinstance(resp.get("a11y"), dict):
tree, truncated = self._guard.truncate_a11y(resp["a11y"])
resp = dict(resp)
resp["a11y"] = tree
resp["truncated"] = bool(resp.get("truncated")
or truncated)
return self._sync_paused(resp)
def snapshot(self):
if self._paused:
return self._blocked_while_paused()
resp = self._call({"op": "snapshot"})
if resp.get("ok") and isinstance(resp.get("a11y"), dict):
tree, truncated = self._guard.truncate_a11y(resp["a11y"])
resp = dict(resp)
resp["a11y"] = tree
resp["truncated"] = bool(resp.get("truncated")
or truncated)
return self._sync_paused(resp)
def screenshot(self):
if self._paused:
return self._blocked_while_paused()
return self._sync_paused(self._call({"op": "screenshot"}))
def close(self):
if self._paused:
return self._blocked_while_paused()
resp = self._call({"op": "close"})
return self._sync_paused(resp)
def pause(self):
resp = self._call({"op": "pause"})
if resp.get("ok"):
self._paused = True
return resp
def resume(self):
resp = self._call({"op": "resume"})
if resp.get("ok"):
self._paused = False
return resp
def status(self):
resp = self._call({"op": "status"})
if isinstance(resp, dict) and "paused" in resp:
self._paused = bool(resp["paused"])
return resp
def list_asks(self):
return self._call({"op": "list_asks"})
def resolve_ask(self, ask_id, decision):
resp = self._call({"op": "resolve_ask", "ask_id": ask_id,
"decision": decision})
if resp.get("ok") and resp.get("decision") == "allowed":
self._paused = False
return resp
def handle_request(agent, req):
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。"""
guard = _load_guard()
if not isinstance(req, dict):
return {"ok": False, "error": "请求须是 JSON 对象"}
op = req.get("op")
if op in guard.FORBIDDEN_OPS:
return {"ok": False,
"error": "操作 %r 不暴露(无 JS/无原始 DOM/无交互)"
% op}
if op == "ping":
return {"ok": True, "browser": True}
if op == "open":
return agent.open(req.get("url"))
if op == "snapshot":
return agent.snapshot()
if op == "screenshot":
return agent.screenshot()
if op == "close":
return agent.close()
if op == "pause":
return agent.pause()
if op == "resume":
return agent.resume()
if op == "status":
return agent.status()
if op == "list_asks":
return agent.list_asks()
if op == "resolve_ask":
return agent.resolve_ask(req.get("ask_id"),
req.get("decision"))
return {"ok": False,
"error": "未知操作:%r(白名单:open/snapshot/screenshot/"
"close/pause/resume/status/list_asks/resolve_ask/"
"ping)" % (op,)}
def _handle_conn(agent, conn):
"""一个连接一请求一响应;回包先过泄露检查,不过即换通用错;
错误信息过消毒,绝不把页面原文原样放进回包。"""
guard = _load_guard()
conn.settimeout(120)
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:
try:
resp = handle_request(agent, req)
except Exception as e:
resp = {"ok": False,
"error": guard.sanitize_error(e)}
if guard.response_leaks(resp):
resp = {"ok": False, "error": "响应泄露检查未通过(已拦截)"}
conn.sendall((json.dumps(resp, ensure_ascii=False) + "\n").encode())
def serve(sock_path=BROWSER_SOCK, cdp_sock_path=CDP_SOCK):
"""监听 Unix socket,逐连接处理。socket 0600。建不起来即抛错,
不要静默带病跑。"""
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)
os.chmod(sock_path, 0o600)
print("chv-browser: 监听 %s" % sock_path, file=sys.stderr, flush=True)
agent = BrowserAgent(cdp_sock_path=cdp_sock_path)
try:
while True:
try:
conn, _ = srv.accept()
except OSError:
continue
try:
_handle_conn(agent, conn)
except OSError as e:
print("chv-browser: 连接处理异常:%s"
% _load_guard().sanitize_error(e),
file=sys.stderr, flush=True)
finally:
try:
conn.close()
except OSError:
pass
finally:
pass
def main(argv):
sock_path = BROWSER_SOCK
cdp_sock_path = CDP_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] == "--cdp-socket" and i + 1 < len(argv):
cdp_sock_path = argv[i + 1]
i += 2
else:
print("chv-browser: 未知参数:%s" % argv[i], file=sys.stderr)
return 2
serve(sock_path, cdp_sock_path)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))