Files
codex-app-server/chv-guest/chv-gitea-worker.py
T
host 81e4eaf4db 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。
2026-11-06 22:31:54 +00:00

334 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())