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:
commit
b0b9f5ed9d
10 files changed
+2072
No files matched your search
@@ -2446,6 +2446,7 @@ class CloudHypervisorExecutor(Executor):
|
||||
ssh_cmd = self.build_ssh_command(
|
||||
task, self._guest_host_command(*inner_args, **kwargs),
|
||||
extra_env=extra_env)
|
||||
self._audit_inference_request(task, ssh_cmd)
|
||||
result = self._call_runner(ssh_cmd, timeout=self.task_timeout)
|
||||
# 方法内 import:见模块头注释的循环导入说明。
|
||||
from cloud.sandbox import SimpleNamespaceShim, _parse_host_result
|
||||
@@ -2454,8 +2455,31 @@ class CloudHypervisorExecutor(Executor):
|
||||
parsed["executor"] = "cloud-hypervisor"
|
||||
parsed["vm"] = self.vm_name(task)
|
||||
parsed["api_socket"] = self.api_socket(task)
|
||||
self._audit_inference_response(task, parsed)
|
||||
return parsed
|
||||
|
||||
def _audit_inference_request(self, task, ssh_cmd):
|
||||
"""推理边界审计(feat-chv-muse-p6,hatch-safety 的确定性
|
||||
等价):记一次进推理的请求。只记指纹,不记值;失败
|
||||
best-effort,绝不中断任务(沿用 _emit 的既定语义)。"""
|
||||
try:
|
||||
from cloud.chv.safety import audit_inference_request
|
||||
audit_inference_request(
|
||||
str(task.get("id", "")), "dispatch",
|
||||
" ".join(str(part) for part in ssh_cmd))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _audit_inference_response(self, task, parsed):
|
||||
"""推理边界审计:记一次出推理的响应(附结果状态)。"""
|
||||
try:
|
||||
from cloud.chv.safety import audit_inference_response
|
||||
audit_inference_response(
|
||||
str(task.get("id", "")), "result",
|
||||
str(parsed.get("status", "")))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _goal_result(self, task_id, result):
|
||||
"""客内 host.py goal 输出解析:直接委托给三执行器共享
|
||||
的 parse_goal_result(cloud/sandbox.py,Local 的
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,281 @@
|
||||
"""确定性安全检查层(feat-chv-muse-p6,叶子模块)。
|
||||
|
||||
对齐 Muse 官方安全文(security.muse.ai 2026-09-08,见
|
||||
docs/chv-muse-p6-gap.md):Harness 层外部数据进模型上下文时标注为
|
||||
“不受信任输入”;独立分类器层多组提示注入检测分类器并行运行,覆盖
|
||||
经文件与工具调用进入上下文的全部外部数据;hatch-safety 检查进出
|
||||
核心模型推理的请求/响应。
|
||||
|
||||
本模块是确定性规则版,不是模型分类器,不冒充(见差距表 §3.3):
|
||||
同一输入永远同一 verdict,可复现、可测试;能拦什么、不能拦什么
|
||||
单列在差距表里。只用标准库,不依赖 cloud.*,客内 worker 守护进程
|
||||
可直接 import 它(见 chv-guest/chv-gitea-worker.py)。
|
||||
|
||||
三件套:
|
||||
- 来源标记:`UntrustedData`(内容 + 来源 + 可信标志),随数据走;
|
||||
- 注入扫描:`InjectionScanner`,分级 block/flag/allow;
|
||||
- 推理边界审计:`AuditLogger`,只记指纹与 verdict,不记值;密钥
|
||||
与 token 值先过 `redact_secrets` 再记,绝不进审计。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
_LOGGER = logging.getLogger("chv_safety")
|
||||
|
||||
# 注入规则:(rule_id, tier, 正则)。tier 只分 block / flag 两档;
|
||||
# 无命中即 allow。block 只给高置信模式;flag 给中置信,命中即
|
||||
# 标记复核、不自动执行后续写操作。
|
||||
_RULES: tuple[tuple[str, str, str], ...] = (
|
||||
# shell 执行链:curl/wget 的输出直接 piped 给 shell。
|
||||
("shell_pipe", "block",
|
||||
r"(?i)\b(curl|wget)\b[^|\n]{0,120}\|\s*(sh|bash|zsh|dash)\b"),
|
||||
# 命令替换里藏危险动词。
|
||||
("cmd_substitution", "block",
|
||||
r"\$\([^)]*\b(rm|sudo|eval|exec|curl|wget|chmod|chown|kill|dd)\b"),
|
||||
("backtick_command", "block",
|
||||
r"`[^`]*\b(rm|sudo|eval|exec|curl|wget)\b[^`]*`"),
|
||||
# 直接指令覆盖(英文)。
|
||||
("instruction_override_en", "block",
|
||||
r"(?i)\b(ignore|disregard|override|forget)\s+(all\s+)?"
|
||||
r"(previous|prior|above|your|system)\s+(system\s+)?"
|
||||
r"(instructions?|prompts?|rules?|guidelines?)"),
|
||||
("instruction_role_en", "block",
|
||||
r"(?i)\byou are now\b.{0,40}\b(follow|obey|execute|do)\b"),
|
||||
# 直接指令覆盖(中文)。
|
||||
("instruction_override_zh", "block",
|
||||
r"忽略(之前|以前|上述)?(的)?(所有)?(的)?"
|
||||
r"(指令|提示|指示|系统提示)"),
|
||||
("instruction_role_zh", "block",
|
||||
r"你现在是.{0,20}(助手|模型|系统|AI|机器人)"),
|
||||
# rm -rf 根目录 / 家目录(路径后不能再跟内容,/tmp/xxx 不算)。
|
||||
("rm_rf_root", "block",
|
||||
r"\brm\s+-[a-z]*r[a-z]*f\s+(/|~|/home|/etc|/usr)(?![\w/])"),
|
||||
# URL 里带 userinfo 口令(凭证泄露形态)。
|
||||
("url_userinfo", "block",
|
||||
r"(?i)\bhttps?://[^\s/:@]+:[^\s@/]+@[^\s/]+"),
|
||||
# data:/javascript: 伪协议。
|
||||
("dangerous_scheme", "block",
|
||||
r"(?i)\b(data|javascript|vbscript):[^\s]*"),
|
||||
# 代码执行原语(中置信:只 flag,不 block——正常文本里也可能
|
||||
# 出现这些词,block 会误伤)。
|
||||
("code_exec_primitive", "flag",
|
||||
r"(?i)\b(eval\s*\(|exec\s*\(|os\.system|subprocess\.|__import__\s*\()"),
|
||||
# 指令性文本(中置信)。
|
||||
("instruction_like_en", "flag",
|
||||
r"(?i)\b(follow these instructions|do not tell the user|"
|
||||
r"keep this (secret|confidential)|do not (mention|reveal|disclose))"),
|
||||
("instruction_like_zh", "flag",
|
||||
r"(不要告诉用户|不要让用户知道|秘密执行|不要透露|不要提及)"),
|
||||
# 可疑 URL(中置信):http 明文、IP 字面量主机、短链。
|
||||
("suspicious_url", "flag",
|
||||
r"(?i)\bhttp://(?:\d{1,3}\.){3}\d{1,3}\b|"
|
||||
r"\bhttps?://(?:bit\.ly|t\.co|tinyurl\.com|goo\.gl|is\.gd)/"),
|
||||
# 可疑拼接:分隔线后紧跟指令性文本。
|
||||
("suspicious_concatenation", "flag",
|
||||
r"(?m)^[-=*_]{3,}\s*$\n?.{0,200}?"
|
||||
r"(ignore|disregard|you are now|忽略|你现在是)"),
|
||||
)
|
||||
|
||||
# 脱敏模式:(正则, 替换)。只留键名/结构,值一律换成 [redacted]。
|
||||
_REDACT_RULES: tuple[tuple[str, str], ...] = (
|
||||
(r"(?i)(gitea_token|token|password|passwd|secret|api[_-]?key|"
|
||||
r"access[_-]?key)\s*[:=]\s*[\"']?\S+", r"\1=[redacted]"),
|
||||
(r"sk-[A-Za-z0-9]{10,}", "[redacted]"),
|
||||
(r"ghp_[A-Za-z0-9]{10,}", "[redacted]"),
|
||||
(r"gho_[A-Za-z0-9]{10,}", "[redacted]"),
|
||||
(r"github_pat_[A-Za-z0-9_]{10,}", "[redacted]"),
|
||||
(r"(?i)Authorization:[^\n]*", "Authorization: [redacted]"),
|
||||
(r"(?i)\bhttps?://[^\s/:@]+:[^\s@/]+@[^\s/]+",
|
||||
"https://[redacted]@"),
|
||||
)
|
||||
|
||||
_COMPILED_RULES: tuple[tuple[str, str, "re.Pattern[str]"], ...] = tuple(
|
||||
(rule_id, tier, re.compile(pattern))
|
||||
for rule_id, tier, pattern in _RULES
|
||||
)
|
||||
|
||||
_COMPILED_REDACT: tuple[tuple["re.Pattern[str]", str], ...] = tuple(
|
||||
(re.compile(pattern), repl) for pattern, repl in _REDACT_RULES
|
||||
)
|
||||
|
||||
|
||||
def redact_secrets(text: str) -> str:
|
||||
"""脱敏:把疑似密钥/口令的值换成 [redacted],只留键名与结构。
|
||||
输入非 str 即返回空串,绝不抛错。"""
|
||||
if not isinstance(text, str):
|
||||
return ""
|
||||
out = text
|
||||
for pattern, repl in _COMPILED_REDACT:
|
||||
out = pattern.sub(repl, out)
|
||||
return out
|
||||
|
||||
|
||||
def content_fingerprint(text: str) -> str:
|
||||
"""内容指纹(审计里唯一允许出现的内容派生形式):先脱敏再
|
||||
SHA-256,取前 16 位 hex。指纹不可逆推出原值。"""
|
||||
cleaned = redact_secrets(text)
|
||||
return hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
class Finding:
|
||||
"""一条规则命中:规则 id + 分级 + 截断后的证据片段(已脱敏)。"""
|
||||
|
||||
def __init__(self, rule: str, tier: str, excerpt: str) -> None:
|
||||
self.rule = rule
|
||||
self.tier = tier
|
||||
self.excerpt = redact_secrets(excerpt)[:120]
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""审计用描述(只含规则与分级,不含原文)。"""
|
||||
return {"rule": self.rule, "tier": self.tier}
|
||||
|
||||
|
||||
class ScanResult:
|
||||
"""一次扫描结果:verdict + 命中列表。verdict 只取 block /
|
||||
flag / allow 三档;block 命中一个即 block。"""
|
||||
|
||||
def __init__(self, verdict: str, findings: list) -> None:
|
||||
self.verdict = verdict
|
||||
self.findings = list(findings)
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""审计用描述(只含 verdict 与规则名,不含原文)。"""
|
||||
return {"verdict": self.verdict,
|
||||
"rules": [f.rule for f in self.findings]}
|
||||
|
||||
|
||||
class InjectionScanner:
|
||||
"""确定性注入扫描器:规则全跑完再定级;同一输入永远同一
|
||||
verdict。空文本即 allow,不抛错。"""
|
||||
|
||||
def scan(self, text: str) -> ScanResult:
|
||||
"""扫描一段外部文本,返回分级结果。"""
|
||||
if not isinstance(text, str) or not text:
|
||||
return ScanResult("allow", [])
|
||||
findings = []
|
||||
for rule_id, tier, pattern in _COMPILED_RULES:
|
||||
match = pattern.search(text)
|
||||
if match is not None:
|
||||
findings.append(Finding(rule_id, tier, match.group(0)))
|
||||
verdict = "allow"
|
||||
if any(f.tier == "block" for f in findings):
|
||||
verdict = "block"
|
||||
elif findings:
|
||||
verdict = "flag"
|
||||
return ScanResult(verdict, findings)
|
||||
|
||||
|
||||
class UntrustedData:
|
||||
"""带来源标记的外部数据:内容 + 来源 + 可信标志 + 收到时间。
|
||||
|
||||
默认不受信任(trusted=False);只有调用方核实过本地来源
|
||||
(如本仓代码、本地配置)才用 mark_trusted。标记随数据走,
|
||||
交给模型前须经 check_context_input。"""
|
||||
|
||||
def __init__(self, content: str, source: str, trusted: bool,
|
||||
received_at: float) -> None:
|
||||
self.content = content
|
||||
self.source = source
|
||||
self.trusted = trusted
|
||||
self.received_at = received_at
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""审计用描述(只含来源与可信标志,不含内容)。"""
|
||||
return {"source": self.source, "trusted": self.trusted,
|
||||
"length": len(self.content),
|
||||
"fingerprint": content_fingerprint(self.content)}
|
||||
|
||||
|
||||
def mark_untrusted(content: str, source: str) -> UntrustedData:
|
||||
"""把一段外部数据标成不受信任(来源如 gitea:<owner/repo>)。"""
|
||||
return UntrustedData(content, source, False, time.time())
|
||||
|
||||
|
||||
def mark_trusted(content: str, source: str) -> UntrustedData:
|
||||
"""把一段已核实本地来源的数据标成可信。只给本地来源用;
|
||||
远端拉回的数据一律走 mark_untrusted,绝不直接标可信。"""
|
||||
return UntrustedData(content, source, True, time.time())
|
||||
|
||||
|
||||
def check_context_input(data: object,
|
||||
scanner: InjectionScanner | None = None
|
||||
) -> ScanResult:
|
||||
"""数据进模型上下文前的检查入口:扫一遍再定级。
|
||||
|
||||
受信任的数据只 flag、不 block(本仓控制面自身的文本不应被
|
||||
规则直接阻断,flag 进审计复核);不受信任的数据按规则全分级。
|
||||
返回的 ScanResult 由调用方执行:block 即阻断、flag 即标记复核。
|
||||
"""
|
||||
text = ""
|
||||
trusted = False
|
||||
if isinstance(data, UntrustedData):
|
||||
text = data.content
|
||||
trusted = data.trusted
|
||||
elif isinstance(data, str):
|
||||
text = data
|
||||
result = (scanner or InjectionScanner()).scan(text)
|
||||
if trusted and result.verdict == "block":
|
||||
capped = [Finding(f.rule, "flag", f.excerpt)
|
||||
for f in result.findings]
|
||||
return ScanResult("flag", capped)
|
||||
return result
|
||||
|
||||
|
||||
class AuditLogger:
|
||||
"""推理边界审计(hatch-safety 的确定性等价):记进出核心模型
|
||||
推理的请求/响应。只记任务 id、kind、内容指纹、长度、verdict;
|
||||
内容值绝不进审计(先脱敏再算指纹)。emit 回调失败只记日志,
|
||||
绝不抛错、不中断任务(沿用执行器 _emit 的既定语义)。"""
|
||||
|
||||
def __init__(self, emit: Callable[[dict], None] | None = None
|
||||
) -> None:
|
||||
self._emit = emit
|
||||
|
||||
def _record(self, event: str, task_id: str, kind: str,
|
||||
content: str, verdict: str) -> dict:
|
||||
record = {"event": event, "task_id": task_id, "kind": kind,
|
||||
"fingerprint": content_fingerprint(content),
|
||||
"length": len(content) if isinstance(content, str)
|
||||
else 0,
|
||||
"verdict": verdict}
|
||||
try:
|
||||
if self._emit is not None:
|
||||
self._emit(record)
|
||||
else:
|
||||
_LOGGER.info("safety_audit %s", record)
|
||||
except Exception as exc: # 审计失败不中断任务
|
||||
_LOGGER.error("safety_audit_failed event=%s error=%s",
|
||||
event, exc)
|
||||
return record
|
||||
|
||||
def log_request(self, task_id: str, kind: str, content: str
|
||||
) -> dict:
|
||||
"""记一次进推理的请求(任务 → 模型)。"""
|
||||
return self._record("safety_inference_request", task_id,
|
||||
kind, content, "allow")
|
||||
|
||||
def log_response(self, task_id: str, kind: str, content: str,
|
||||
verdict: str = "allow") -> dict:
|
||||
"""记一次出推理的响应(模型 → 调用方),附扫描 verdict。"""
|
||||
return self._record("safety_inference_response", task_id,
|
||||
kind, content, verdict)
|
||||
|
||||
|
||||
DEFAULT_AUDIT_LOGGER = AuditLogger()
|
||||
|
||||
|
||||
def audit_inference_request(task_id: str, kind: str, content: str
|
||||
) -> dict:
|
||||
"""模块级快捷方式:记一次进推理的请求(默认 logger)。"""
|
||||
return DEFAULT_AUDIT_LOGGER.log_request(task_id, kind, content)
|
||||
|
||||
|
||||
def audit_inference_response(task_id: str, kind: str, content: str,
|
||||
verdict: str = "allow") -> dict:
|
||||
"""模块级快捷方式:记一次出推理的响应(默认 logger)。"""
|
||||
return DEFAULT_AUDIT_LOGGER.log_response(task_id, kind, content,
|
||||
verdict)
|
||||
Reference in new issue
Block a user