"""确定性安全检查层(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:)。""" 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)