feat(chv): 对齐 Muse P6 privsep worker + 确定性安全检查层

- cloud/chv/privsep.py(新):Gitea worker 注册表(凭证白名单只含
  GITEA_TOKEN)+ 类型化请求/响应(request_id 绑定、64KB 上限)+
  Unix socket 客户端(超时/崩溃 -> 类型化失败 WorkerRefusal)+
  客户端白名单校验
- cloud/chv/safety.py(新):UntrustedData 来源标记 +
  InjectionScanner 确定性规则(block/flag/allow,中英)+
  redact_secrets + AuditLogger(只记指纹,不记值)+
  audit_inference_request/response
- chv-guest/chv-gitea-worker.py + .service(新):systemd 沙箱化
  worker(SO_PEERCRED 只放 root,仓限 ~/workspace,git 只配 authd
  helper,绝不持 token;输出先扫描再返回)
- chv-guest/chv-sync-worker-client.py(新):agent 侧薄 CLI
- chv-guest/chv-sync.sh:pull/push/status 有 socket 时走 worker,
  否则旧路径 + stderr 警告(过渡兼容)
- cloud/chv/executor.py:_run_guest 前后加推理边界审计钩子
  (best-effort)
- docs/chv-muse-p6-gap.md(新):差距表 + 覆盖边界 + 真机待验证清单
- docs/chv-production-readiness.md:§15
- cloud/tests/test_chv_muse_p6.py(新):43 项

测试:新测试 43/43;全量 cloud 套件 1910 OK(skipped=9);mypy
在新增文件上干净(剩余 4 处为基线旧错 executor.py:1396/1401、
marker_tools.py:142,未动)。
真机验证:未做,排队在 round-11 之后,清单见
docs/chv-muse-p6-gap.md §4。
This commit is contained in:
host committed 2026-11-06 22:31:54 +00:00
1 parent 06c3e172fe
commit 81e4eaf4db
10 files changed
+2071

No files matched your search

+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env python3
"""chv-gitea-worker:Gitea 同步 privsep worker(feat-chv-muse-p6)。
对齐 Muse 官方安全文:内置连接器的业务逻辑由 systemd 沙箱化的
privsep worker 执行;每个 worker 由 cgroup 标识、有明确凭证白名单。
本进程跑在客内、运行时单元之外(root 身份的 systemd 服务,见
chv-gitea-worker.service;P2 的 nspawn 单元落地后,codex 在单元内,
本进程在单元外——单元内 root 映射为非特权用户,读不到本进程的
socket)。它只做 Gitea 同步的 git 业务逻辑:
- 在 Unix socket /run/chv-gitea/worker.sock 上接受 broker/薄 CLI
的类型化请求(SO_PEERCRED 只放行 root;agent 在单元内只能请
broker 代办,连不上本 socket);
- 操作白名单只有 pull/push/status/ping;仓库只限 ~/workspace 下;
- git 以 chv 身份跑(runuser -u chv,文件归属不变),argv 里只配
credential helper(P3 的 git-credential-chv-authd,见下),绝无
token 值;凭证由 git 经 helper 向 authd 按次取,本进程绝不持有
token;
- git 输出先过 safety 扫描 + 打上来源标记再返回;block 即阻断
(内容不往回流),flag 即标记复核。
只用标准库。cloud/chv/privsep.py 与 cloud/chv/safety.py 是叶子
模块(只 import 标准库),镜像构建时随本文件一起拷进
/usr/local/lib/chv-gitea/(见 docs/chv-muse-p6-gap.md §2 的接入
说明),本文件把该目录加进 sys.path 后 import。
"""
import argparse
import json
import os
import socket
import socketserver
import struct
import subprocess
import sys
import threading
DEFAULT_LIB_DIR = "/usr/local/lib/chv-gitea"
DEFAULT_SOCKET_PATH = "/run/chv-gitea/worker.sock"
DEFAULT_LOG_PATH = "/var/log/chv-gitea-worker.log"
WORKSPACE = "/home/chv/workspace"
SYNC_USER = "chv"
# P3 的 credential helper(git 经它向 authd 按次取凭证)与 path
# 配置(P3 修过的坑:git 默认不把 path 发给 helper)。
HELPER_PATH = "/usr/local/bin/git-credential-chv-authd"
GIT_TIMEOUT_SECONDS = 300
MAX_REQUEST_BYTES = 65536
def _audit(log_path, record):
"""审计落盘(JSON 行,0600):只含绑定与 verdict,不含值。
落盘失败只记 stderr,绝不炸 worker。"""
line = json.dumps(record, sort_keys=True)
try:
with open(log_path, "a") as fh:
fh.write(line + "\n")
except OSError as exc:
print("chv-gitea-worker: audit 落盘失败:%s" % exc,
file=sys.stderr)
print("chv-gitea-worker: %s" % line, file=sys.stderr)
def _git_base_argv():
"""git 基础 argv:只配 credential helper,不含任何凭证值。
helper 缺失时由调用方先拒绝(fail-closed),不回落读 token。"""
return ["git", "-c",
"credential.helper=%s" % HELPER_PATH,
"-c", "credential.useHttpPath=true"]
def _resolve_repo(repo, workspace):
"""解析仓目录:显式给则用它(须在 workspace 下,调用方已
校验);省略时取 workspace 下唯一的 git 仓库;0 个或多个
即 ValueError(由调用方转成类型化失败)。"""
if repo:
return repo
found = []
try:
names = sorted(os.listdir(workspace))
except OSError as exc:
raise ValueError("工作区读不到:%s" % exc)
for name in names:
path = os.path.join(workspace, name)
if os.path.isdir(os.path.join(path, ".git")):
found.append(path)
if len(found) == 1:
return found[0]
raise ValueError(
"workspace 下 git 仓库不是唯一(%d 个),请显式给仓目录"
% len(found))
def _run_git(action, repo, run_git=None):
"""跑一次 git(pull/push/status),返回 (returncode, stdout,
stderr)。run_git 可注入(测试用假 git,不跑真 git)。
git 以 chv 身份跑,文件归属不变;argv 是 list,不进 shell。"""
if run_git is not None:
return run_git(action, repo)
argv = ["runuser", "-u", SYNC_USER, "--", "env",
"HOME=/home/chv"] + _git_base_argv()
if action == "pull":
argv += ["-C", repo, "pull", "--ff-only"]
elif action == "push":
argv += ["-C", repo, "push"]
elif action == "status":
argv += ["-C", repo, "status", "-sb"]
else:
return 2, "", "未知 git 操作:%s" % action
try:
proc = subprocess.run(argv, capture_output=True, text=True,
timeout=GIT_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
return 124, "", "git 超时(%d 秒)" % GIT_TIMEOUT_SECONDS
except OSError as exc:
return 2, "", "git 起不来:%s" % exc
return proc.returncode, proc.stdout, proc.stderr
def _truncate(text, limit=8192):
if text is None:
return ""
if len(text) > limit:
return text[:limit] + "\n…(已截断)"
return text
class WorkerHandler(socketserver.BaseRequestHandler):
"""单次请求处理(每连接一 JSON 行):鉴权 → 校验 → 跑 git →
扫描输出 → 回包。任何一步拒绝都回 {ok:false},不含值。"""
def handle(self):
server = self.server
try:
creds = self.request.getsockopt(
socket.SOL_SOCKET, socket.SO_PEERCRED,
struct.calcsize("3i"))
_pid, uid, _gid = struct.unpack("3i", creds)
except (OSError, struct.error):
self._reply({"ok": False, "error": "peercred_failed",
"stdout": "", "stderr": "",
"returncode": 2, "safety": {},
"request_id": ""})
return
if uid not in server.allowed_uids:
server.audit({"event": "privsep_peer_rejected",
"uid": uid})
self._reply({"ok": False, "error": "uid_not_allowed",
"stdout": "", "stderr": "",
"returncode": 2, "safety": {},
"request_id": ""})
return
try:
buf = b""
while b"\n" not in buf:
chunk = self.request.recv(4096)
if not chunk:
break
buf += chunk
if len(buf) > MAX_REQUEST_BYTES:
break
req = json.loads(buf.decode("utf-8"))
except (OSError, ValueError):
self._reply({"ok": False, "error": "bad_request",
"stdout": "", "stderr": "",
"returncode": 2, "safety": {},
"request_id": ""})
return
self._serve(req)
def _reply(self, obj):
try:
self.request.sendall(
(json.dumps(obj) + "\n").encode("utf-8"))
except OSError:
pass
def _serve(self, req):
server = self.server
try:
from cloud.chv.privsep import (
WorkerRequest, WorkerResponse)
request = WorkerRequest.from_dict(
req, workspace=server.workspace)
except Exception as exc:
kind = getattr(exc, "kind", "bad_request")
server.audit({"event": "privsep_request_refused",
"kind": kind})
self._reply({"ok": False, "error": kind,
"stdout": "", "stderr": "",
"returncode": 2, "safety": {},
"request_id": ""})
return
if request.op == "ping":
resp = WorkerResponse(True, "", "", 0,
{"verdict": "allow", "rules": []},
request.request_id)
self._reply(resp.to_dict())
return
if not os.path.exists(server.helper_path):
server.audit({"event": "privsep_request_refused",
"kind": "credential_helper_missing",
"op": request.op})
self._reply({"ok": False,
"error": "credential_helper_missing",
"stdout": "", "stderr": "",
"returncode": 2, "safety": {},
"request_id": request.request_id})
return
try:
repo_path = _resolve_repo(request.repo, server.workspace)
except ValueError as exc:
server.audit({"event": "privsep_request_refused",
"kind": "repo_not_unique",
"op": request.op})
self._reply({"ok": False, "error": "repo_not_unique",
"stdout": "", "stderr": str(exc),
"returncode": 2, "safety": {},
"request_id": request.request_id})
return
self._run_and_reply(request, repo_path)
def _run_and_reply(self, request, repo_path):
server = self.server
from cloud.chv.privsep import WorkerResponse
from cloud.chv.safety import (
check_context_input, mark_untrusted, redact_secrets)
code, out, err = _run_git(request.op, repo_path,
run_git=server.run_git)
source = "gitea:%s" % (request.repo or "unknown")
checked = check_context_input(
mark_untrusted(out + "\n" + err, source))
safety = checked.describe()
# 脱敏后再返回:git 输出万一含 token,不原样回流。
out = redact_secrets(out)
err = redact_secrets(err)
server.audit({"event": "privsep_worker_call",
"op": request.op, "repo": request.repo,
"returncode": code,
"safety": safety,
"cgroup": server.cgroup_id})
if checked.verdict == "block":
self._reply({"ok": False,
"error": "safety_blocked:%s" % ",".join(
f.rule for f in checked.findings),
"stdout": "", "stderr": "",
"returncode": code, "safety": safety,
"request_id": request.request_id})
return
resp = WorkerResponse(code == 0, _truncate(out),
_truncate(err), code, safety,
request.request_id)
self._reply(resp.to_dict())
class WorkerServer(socketserver.ThreadingUnixStreamServer):
"""Unix socket 服务(线程化,daemon_threads=True 避免僵尸线程)。"""
daemon_threads = True
allow_reuse_address = False
def __init__(self, socket_path, workspace, log_path,
allowed_uids=(0,), run_git=None,
helper_path=HELPER_PATH):
self.workspace = workspace
self.log_path = log_path
self.allowed_uids = frozenset(allowed_uids)
self.run_git = run_git
self.helper_path = helper_path
try:
with open("/proc/self/cgroup") as fh:
self.cgroup_id = fh.readline().strip()
except OSError:
self.cgroup_id = ""
directory = os.path.dirname(socket_path)
if directory:
os.makedirs(directory, exist_ok=True)
try:
os.chmod(directory, 0o700)
except OSError:
pass
if os.path.exists(socket_path):
os.unlink(socket_path)
super().__init__(socket_path, WorkerHandler)
try:
os.chmod(socket_path, 0o600)
except OSError:
pass
def audit(self, record):
_audit(self.log_path, record)
def build_server(socket_path=DEFAULT_SOCKET_PATH,
workspace=WORKSPACE,
log_path=DEFAULT_LOG_PATH,
lib_dir=DEFAULT_LIB_DIR,
allowed_uids=None,
run_git=None,
helper_path=HELPER_PATH):
"""建服务(不启动):socket + 工作区 + 审计路径就绪即返回。
helper 缺失不在建服务时报错——建服务时只备好 socket,git
调用时再拒绝(fail-closed,见 _serve)。"""
if lib_dir not in sys.path:
sys.path.insert(0, lib_dir)
uids = list(allowed_uids) if allowed_uids is not None else [0]
return WorkerServer(socket_path, workspace, log_path, uids,
run_git=run_git, helper_path=helper_path)
def main(argv=None):
"""前台运行(systemd Type=simple 直接跑本进程,不 daemonize)。"""
parser = argparse.ArgumentParser()
parser.add_argument("--socket", default=DEFAULT_SOCKET_PATH)
parser.add_argument("--workspace", default=WORKSPACE)
parser.add_argument("--log", default=DEFAULT_LOG_PATH)
parser.add_argument("--lib", default=DEFAULT_LIB_DIR)
args = parser.parse_args(argv)
server = build_server(socket_path=args.socket,
workspace=args.workspace,
log_path=args.log, lib_dir=args.lib)
print("chv-gitea-worker: listening on %s (cgroup %s)"
% (args.socket, server.cgroup_id), file=sys.stderr)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())