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,312 @@
|
||||
"""privsep worker 协议与客户端(feat-chv-muse-p6,叶子模块)。
|
||||
|
||||
对齐 Muse 官方安全文(security.muse.ai 2026-09-08,见
|
||||
docs/chv-muse-p6-gap.md):单元内的连接器 CLI 只解析参数、通过
|
||||
Unix socket 传递类型化参数与文件描述符;真正业务逻辑由 systemd
|
||||
沙箱化的 privsep worker 执行;每个 worker 由 cgroup 标识、有明确
|
||||
凭证白名单(改参数也索取不到别的凭证)。
|
||||
|
||||
本模块是协议 + 客户端 + 注册表(只用标准库,不依赖 cloud.*,
|
||||
客内 worker 守护进程可直接 import 它):
|
||||
- `WORKER_SPECS`:本期只认一个 worker(gitea),凭证白名单只含
|
||||
GITEA_TOKEN,操作只含 pull/push/status/ping,仓库只限
|
||||
~/workspace 下;
|
||||
- `WorkerRequest` / `WorkerResponse`:类型化 JSON 行(≤64KB),
|
||||
请求里绝不带任何凭证字段——凭证只由 git 经 P3 的 credential
|
||||
helper 向 authd 按次取,worker 与客户端都碰不到 token 值;
|
||||
- `PrivsepClient`:发请求前先按白名单校验(越权请求不出门),
|
||||
worker 崩溃/超时只返回类型化失败(WorkerRefusal),不抛原始
|
||||
异常、不拖垮调用方;返回的外部数据先过安全扫描再交给调用方,
|
||||
block 即阻断(内容不往下流)、flag 即标记复核。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
|
||||
from cloud.chv.safety import (
|
||||
InjectionScanner,
|
||||
check_context_input,
|
||||
mark_untrusted,
|
||||
)
|
||||
|
||||
# 客内工作区根(worker 的仓库 confinement 基准)与默认 socket。
|
||||
GUEST_WORKSPACE = "/home/chv/workspace"
|
||||
DEFAULT_SOCKET_PATH = "/run/chv-gitea/worker.sock"
|
||||
MAX_MESSAGE_BYTES = 65536
|
||||
DEFAULT_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
class WorkerRefusal(Exception):
|
||||
"""privsep 拒绝:kind + 原因,不含任何凭证值。调用方按类型化
|
||||
失败处理,不把它当成 agent 崩溃。"""
|
||||
|
||||
def __init__(self, kind: str, message: str) -> None:
|
||||
super().__init__("privsep_refused:%s: %s" % (kind, message))
|
||||
self.kind = kind
|
||||
|
||||
|
||||
class WorkerSpec:
|
||||
"""一个 privsep worker 的注册信息:连接器 + 凭证白名单 +
|
||||
操作白名单 + socket + systemd 单元。"""
|
||||
|
||||
def __init__(self, connector: str, allowed_credentials: frozenset,
|
||||
operations: frozenset, socket_path: str,
|
||||
unit: str) -> None:
|
||||
self.connector = connector
|
||||
self.allowed_credentials = frozenset(allowed_credentials)
|
||||
self.operations = frozenset(operations)
|
||||
self.socket_path = socket_path
|
||||
self.unit = unit
|
||||
|
||||
|
||||
WORKER_SPECS: dict[str, WorkerSpec] = {
|
||||
"gitea": WorkerSpec(
|
||||
connector="gitea",
|
||||
allowed_credentials=frozenset({"GITEA_TOKEN"}),
|
||||
operations=frozenset({"pull", "push", "status", "ping"}),
|
||||
socket_path=DEFAULT_SOCKET_PATH,
|
||||
unit="chv-gitea-worker.service",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def spec_for(connector: str) -> WorkerSpec:
|
||||
"""取某连接器的 worker 注册信息;未知连接器即拒绝(不设默认
|
||||
放行,调用方不得以“没注册”绕过白名单)。"""
|
||||
spec = WORKER_SPECS.get(connector)
|
||||
if spec is None:
|
||||
raise WorkerRefusal(
|
||||
"unknown_connector",
|
||||
"连接器 %s 没有注册的 privsep worker" % connector)
|
||||
return spec
|
||||
|
||||
|
||||
def worker_cgroup_for(pid: int) -> str:
|
||||
"""读 /proc/<pid>/cgroup 的首行(worker 的 cgroup 标识,审计
|
||||
用)。读不到即空串,不抛错。"""
|
||||
try:
|
||||
with open("/proc/%d/cgroup" % pid) as fh:
|
||||
first = fh.readline().strip()
|
||||
return first
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _repo_within_workspace(repo: str, workspace: str) -> str:
|
||||
"""仓库路径 confinement:realpath 后必须在 workspace 之下,
|
||||
且不能就是 workspace 本身。返回解析后的绝对路径,否则
|
||||
WorkerRefusal。"""
|
||||
base = os.path.realpath(workspace)
|
||||
target = os.path.realpath(repo)
|
||||
if os.path.commonpath([base, target]) != base or target == base:
|
||||
raise WorkerRefusal(
|
||||
"repo_outside_workspace",
|
||||
"仓目录只允许 %s 下" % workspace)
|
||||
return target
|
||||
|
||||
|
||||
class WorkerRequest:
|
||||
"""一次类型化 worker 请求:操作 + 连接器 + 仓库 + 请求 id。
|
||||
请求里绝无凭证字段(凭证只走 authd 替身,见差距表 §1)。"""
|
||||
|
||||
def __init__(self, op: str, connector: str,
|
||||
repo: str | None, request_id: str) -> None:
|
||||
self.op = op
|
||||
self.connector = connector
|
||||
self.repo = repo
|
||||
self.request_id = request_id
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""序列化(JSON 行用)。"""
|
||||
return {"op": self.op, "connector": self.connector,
|
||||
"repo": self.repo, "request_id": self.request_id}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: type["WorkerRequest"], data: dict,
|
||||
workspace: str = GUEST_WORKSPACE) -> "WorkerRequest":
|
||||
"""反序列化 + 校验:未知连接器/操作、仓库越界、请求里带
|
||||
凭证字段一律拒绝。"""
|
||||
if not isinstance(data, dict):
|
||||
raise WorkerRefusal(
|
||||
"bad_request", "请求须是 JSON 对象")
|
||||
if "credential" in data or "token" in data \
|
||||
or "password" in data:
|
||||
raise WorkerRefusal(
|
||||
"credential_in_request",
|
||||
"请求里绝不带凭证字段")
|
||||
connector = data.get("connector")
|
||||
if not isinstance(connector, str):
|
||||
raise WorkerRefusal(
|
||||
"bad_request", "connector 缺失或不是字符串")
|
||||
spec = spec_for(connector)
|
||||
op = data.get("op")
|
||||
if not isinstance(op, str):
|
||||
raise WorkerRefusal(
|
||||
"bad_request", "op 缺失或不是字符串")
|
||||
if op not in spec.operations:
|
||||
raise WorkerRefusal(
|
||||
"operation_not_allowed",
|
||||
"操作 %r 不在 %s 的白名单内" % (op, connector))
|
||||
repo = data.get("repo")
|
||||
if repo is not None and repo != "":
|
||||
if not isinstance(repo, str):
|
||||
raise WorkerRefusal(
|
||||
"bad_request", "repo 不是字符串")
|
||||
repo = _repo_within_workspace(repo, workspace)
|
||||
else:
|
||||
repo = None
|
||||
request_id = data.get("request_id")
|
||||
if not isinstance(request_id, str) or not request_id:
|
||||
raise WorkerRefusal(
|
||||
"bad_request", "request_id 缺失或不是字符串")
|
||||
return cls(op, connector, repo, request_id)
|
||||
|
||||
|
||||
class WorkerResponse:
|
||||
"""一次类型化 worker 响应:ok + git 输出 + 返回码 + 安全扫描
|
||||
摘要 + 请求 id。ok 为 False 时 error 说明原因,不含值。"""
|
||||
|
||||
def __init__(self, ok: bool, stdout: str, stderr: str,
|
||||
returncode: int, safety: dict, request_id: str,
|
||||
error: str | None = None) -> None:
|
||||
self.ok = ok
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.returncode = returncode
|
||||
self.safety = dict(safety)
|
||||
self.request_id = request_id
|
||||
self.error = error
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""序列化(JSON 行用)。"""
|
||||
return {"ok": self.ok, "stdout": self.stdout,
|
||||
"stderr": self.stderr, "returncode": self.returncode,
|
||||
"safety": self.safety, "request_id": self.request_id,
|
||||
"error": self.error}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: type["WorkerResponse"], data: dict
|
||||
) -> "WorkerResponse":
|
||||
"""反序列化 + 校验:形状不对即拒绝,不把脏回包当成成功。"""
|
||||
if not isinstance(data, dict) or "ok" not in data:
|
||||
raise WorkerRefusal(
|
||||
"bad_response", "worker 回包格式不对")
|
||||
if not isinstance(data["ok"], bool):
|
||||
raise WorkerRefusal(
|
||||
"bad_response", "worker 回包 ok 不是布尔值")
|
||||
request_id = data.get("request_id")
|
||||
if not isinstance(request_id, str):
|
||||
raise WorkerRefusal(
|
||||
"bad_response", "worker 回包 request_id 不对")
|
||||
return cls(bool(data["ok"]), str(data.get("stdout") or ""),
|
||||
str(data.get("stderr") or ""),
|
||||
int(data.get("returncode") or 0),
|
||||
dict(data.get("safety") or {}), request_id,
|
||||
data.get("error"))
|
||||
|
||||
|
||||
class PrivsepClient:
|
||||
"""privsep 客户端(agent 侧薄 CLI 的宿主版):发请求前按白名单
|
||||
校验,worker 崩溃/超时只给类型化失败;返回的外部数据先过安全
|
||||
扫描再交给调用方——block 即阻断(内容不往下流,只剩原因),
|
||||
flag 即原样返回、附扫描摘要标记复核。"""
|
||||
|
||||
def __init__(self, socket_path: str = DEFAULT_SOCKET_PATH,
|
||||
connector: str = "gitea",
|
||||
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
scanner: InjectionScanner | None = None,
|
||||
workspace: str = GUEST_WORKSPACE) -> None:
|
||||
self.socket_path = socket_path
|
||||
self.connector = connector
|
||||
self.timeout = timeout
|
||||
self.scanner = scanner or InjectionScanner()
|
||||
self.workspace = workspace
|
||||
|
||||
def _check_request(self, request: WorkerRequest) -> WorkerSpec:
|
||||
"""客户端白名单校验:连接器须注册、操作须在白名单内;
|
||||
请求的 repo 须在 workspace 下。越权请求不出门。"""
|
||||
spec = spec_for(request.connector)
|
||||
if request.op not in spec.operations:
|
||||
raise WorkerRefusal(
|
||||
"operation_not_allowed",
|
||||
"操作 %r 不在 %s 的白名单内"
|
||||
% (request.op, request.connector))
|
||||
if request.repo is not None:
|
||||
_repo_within_workspace(request.repo, self.workspace)
|
||||
return spec
|
||||
|
||||
def _send(self, payload: dict) -> dict:
|
||||
"""一请求一响应(JSON 行,≤64KB)。socket 层面的失败
|
||||
全部转成 WorkerRefusal,不抛原始异常。"""
|
||||
try:
|
||||
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
conn.settimeout(self.timeout)
|
||||
conn.connect(self.socket_path)
|
||||
except OSError as exc:
|
||||
raise WorkerRefusal(
|
||||
"worker_unavailable",
|
||||
"worker socket 连不上:%s" % exc)
|
||||
try:
|
||||
conn.sendall((json.dumps(payload) + "\n").encode("utf-8"))
|
||||
data = b""
|
||||
while len(data) < MAX_MESSAGE_BYTES:
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if data.endswith(b"\n"):
|
||||
break
|
||||
except OSError as exc:
|
||||
raise WorkerRefusal(
|
||||
"worker_io_failed",
|
||||
"worker 通信失败:%s" % exc)
|
||||
finally:
|
||||
conn.close()
|
||||
try:
|
||||
parsed = json.loads(data.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
raise WorkerRefusal(
|
||||
"bad_response", "worker 回包不是合法 JSON")
|
||||
if not isinstance(parsed, dict):
|
||||
raise WorkerRefusal(
|
||||
"bad_response", "worker 回包不是 JSON 对象")
|
||||
return parsed
|
||||
|
||||
def call(self, request: WorkerRequest) -> WorkerResponse:
|
||||
"""发一次类型化请求,返回扫描过的类型化响应。"""
|
||||
self._check_request(request)
|
||||
raw = self._send(request.to_dict())
|
||||
response = WorkerResponse.from_dict(raw)
|
||||
if response.request_id != request.request_id:
|
||||
raise WorkerRefusal(
|
||||
"bad_response",
|
||||
"worker 回包 request_id 与请求不一致")
|
||||
source = "gitea:%s" % (request.repo or "unknown")
|
||||
checked = check_context_input(
|
||||
mark_untrusted(response.stdout + "\n" + response.stderr,
|
||||
source),
|
||||
scanner=self.scanner)
|
||||
worker_verdict = response.safety.get("verdict")
|
||||
if worker_verdict in ("block", "flag"):
|
||||
effective = worker_verdict
|
||||
else:
|
||||
effective = checked.verdict
|
||||
response.safety = checked.describe()
|
||||
if effective == "block":
|
||||
rules = response.safety.get("rules", [])
|
||||
if not rules:
|
||||
rules = [f.rule for f in checked.findings]
|
||||
return WorkerResponse(
|
||||
False, "", "", response.returncode,
|
||||
response.safety, response.request_id,
|
||||
"safety_blocked:%s" % ",".join(rules))
|
||||
return response
|
||||
|
||||
def ping(self) -> WorkerResponse:
|
||||
"""worker 存活探针(不跑 git,只走一遍协议与校验)。"""
|
||||
request = WorkerRequest(
|
||||
"ping", self.connector, None,
|
||||
"ping-%d" % int(time.time() * 1000))
|
||||
return self.call(request)
|
||||
Reference in new issue
Block a user