merge(chv-muse-round12): 合入 P6 privsep worker + 确定性安全检查层 (81e4eaf)

唯一冲突是文档 §15 撞号:P6 一节顺延为 §17,基线说明更新
(P3 已在树里,接线走真实代码)。chv-sync.sh 自动合入正确:
P6 的 worker 路径与 P3 的 authd 路径(useHttpPath)都在。
P6 测试 43 项全过(skipped=1 是 root 下 uid 否定测试)。
This commit is contained in:
host committed 2026-11-07 00:02:22 +00:00
commit b0b9f5ed9d
10 files changed
+2072

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())
+32
View File
@@ -0,0 +1,32 @@
[Unit]
Description=CHV Gitea sync privsep worker (feat-chv-muse-p6)
After=network-online.target chv-authd.service
Wants=network-online.target
# authd 是可选依赖:worker 的 git 经 P3 helper 向 authd 取凭证;
# authd 没起来时 worker 的 git 调用直接拒绝,不静默用旧凭证。
[Service]
Type=simple
User=root
Group=root
ExecStart=/usr/local/bin/chv-gitea-worker
Restart=on-failure
RestartSec=2
# systemd 沙箱(与 P3 authd 同款 root 单元外服务口径,再收紧):
# 只读系统、独立 /tmp、不许提权、不留 capability、只许 Unix 与
# inet 协议族、只许 @system-service 系统调用、读写路径只限工作区
# 与 worker 运行时目录。网络层强制(eBPF)是 P5 的范围,不在这里
# 冒充——出口收紧靠 Sentinel 评估(authd 放凭证前)+ 只配 helper
# 的 git(见 chv-gitea-worker.py)。
ProtectSystem=strict
PrivateTmp=yes
NoNewPrivileges=yes
CapabilityBoundingSet=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallFilter=@system-service
ReadWritePaths=/home/chv/workspace /run/chv-gitea /var/log/chv-gitea-worker.log
# worker 崩溃不拖垮 agent:broker/薄 CLI 收到类型化失败即返回,
# 不重试、不挂起;systemd 只负责拉起 worker 本身。
[Install]
WantedBy=multi-user.target
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""chv-sync-worker-client:Gitea worker 薄客户端(feat-chv-muse-p6)。
agent 侧(broker / chv-sync)只剩薄 CLI:解析参数、发类型化 JSON
请求到 worker socket、原样回显 worker 的 stdout/stderr、按 worker
的 returncode 退出。本进程不跑 git、不碰凭证、不做业务逻辑。
用法:chv-sync-worker-client <pull|push|status|ping> [仓目录]
仓目录省略时由 worker 侧取唯一仓(与 chv-sync 同款语义,由 worker
的 repo=None 处理——本客户端只透传,不自己解析)。
只用标准库。
"""
import json
import os
import socket
import sys
import time
DEFAULT_SOCKET_PATH = "/run/chv-gitea/worker.sock"
def _exchange(sock_path, request):
"""连 worker、发请求、收一帧回包。
返回 (resp_dict, error_str),成功时 error_str 为 None。"""
try:
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
conn.settimeout(310)
conn.connect(sock_path)
except OSError as exc:
return None, "worker 连不上:%s" % exc
try:
conn.sendall((json.dumps(request) + "\n").encode("utf-8"))
data = b""
while len(data) < 65536:
chunk = conn.recv(4096)
if not chunk:
break
data += chunk
if data.endswith(b"\n"):
break
except OSError as exc:
return None, "worker 通信失败:%s" % exc
finally:
conn.close()
try:
resp = json.loads(data.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return None, "worker 回包不是 JSON"
if not isinstance(resp, dict) or "ok" not in resp:
return None, "worker 回包格式不对"
return resp, None
def main(argv=None):
"""发一次类型化请求并回显。返回 worker 的 returncode;
通信失败即 exit 2(调用方按失败处理,不静默成功)。"""
args = list(argv if argv is not None else sys.argv[1:])
if not args or args[0] not in ("pull", "push", "status", "ping"):
print("用法:chv-sync-worker-client "
"pull|push|status|ping [仓目录]", file=sys.stderr)
return 2
op = args[0]
repo = args[1] if len(args) > 1 and args[1] else None
sock_path = os.environ.get("CHV_GITEA_WORKER_SOCK",
DEFAULT_SOCKET_PATH)
request = {"op": op, "connector": "gitea", "repo": repo,
"request_id": "cli-%d" % int(time.time() * 1000)}
resp, err = _exchange(sock_path, request)
if err is not None:
print("chv-sync-worker-client: %s" % err, file=sys.stderr)
return 2
if resp.get("stdout"):
sys.stdout.write(resp["stdout"])
if resp.get("stderr"):
sys.stderr.write(resp["stderr"])
if resp.get("error"):
print("chv-sync-worker-client: %s" % resp["error"],
file=sys.stderr)
if resp.get("safety", {}).get("verdict") == "flag":
print("chv-sync-worker-client: 输出已标记复核:%s"
% resp["safety"], file=sys.stderr)
return int(resp.get("returncode") or (0 if resp["ok"] else 1))
if __name__ == "__main__":
raise SystemExit(main())
+26
View File
@@ -33,10 +33,24 @@ GUEST_ENV="${CHV_GUEST_ENV:-/etc/chv/guest-env}"
WORKSPACE="${CHV_WORKSPACE:-/home/chv/workspace}"
USER_HOME="${CHV_USER_HOME:-/home/chv}"
SYNC_USER="${CHV_USER:-chv}"
# privsep worker(feat-chv-muse-p6):socket 在时 pull/push/status
# 改走 worker(类型化请求),不在时走旧的直接路径 + 警告(过渡兼容,
# 旧镜像退役后改为无 worker 即 fail-closed)。
WORKER_SOCK="${CHV_GITEA_WORKER_SOCK:-/run/chv-gitea/worker.sock}"
WORKER_CLIENT="${CHV_GITEA_WORKER_CLIENT:-/usr/local/bin/chv-sync-worker-client}"
die() { echo "chv-sync: $*" >&2; exit 1; }
info() { echo "chv-sync: $*"; }
# 试走 worker:返回 42 = worker 不在(调用方走旧路径);其它返回
# 值即 worker 的退出码(调用方直接退出,不再回落重跑)。
try_worker() {
local action="$1"; shift
[ -S "$WORKER_SOCK" ] || return 42
[ -x "$WORKER_CLIENT" ] || return 42
"$WORKER_CLIENT" "$action" "$@"
}
# 解析仓目录:显式给则用它;否则取 WORKSPACE 下唯一的 git 仓库。
resolve_repo() {
if [ -n "${1:-}" ]; then
@@ -114,6 +128,10 @@ cmd_boot() {
}
cmd_pull() {
local rc=0
try_worker pull "${1:-}" || rc=$?
[ "$rc" != "42" ] && exit "$rc"
echo "chv-sync: worker 不在,走旧的直接路径(过渡兼容)" >&2
local dir
dir="$(resolve_repo "${1:-}")"
[ -z "$(git -C "$dir" status --porcelain)" ] \
@@ -124,6 +142,10 @@ cmd_pull() {
}
cmd_push() {
local rc=0
try_worker push "${1:-}" || rc=$?
[ "$rc" != "42" ] && exit "$rc"
echo "chv-sync: worker 不在,走旧的直接路径(过渡兼容)" >&2
local dir
dir="$(resolve_repo "${1:-}")"
if git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
@@ -137,6 +159,10 @@ cmd_push() {
}
cmd_status() {
local rc=0
try_worker status "${1:-}" || rc=$?
[ "$rc" != "42" ] && exit "$rc"
echo "chv-sync: worker 不在,走旧的直接路径(过渡兼容)" >&2
local dir
dir="$(resolve_repo "${1:-}")"
git -C "$dir" status -sb