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:
1 parent
06c3e172fe
commit
81e4eaf4db
10 files changed
+2071
No files matched your search
@@ -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())
|
||||
Reference in new issue
Block a user