/** * 本地规则校验 —— 零依赖纯函数(§4 职责:最易测)。 * * 三类检查全部**数据驱动**:敏感词命中、长度上下限、格式(空白/控制字符/注入痕迹)。 * 词表与阈值**只来自注入的 `rules`**,包内 grep 不到硬编码词表(验收 2/7)。 * 加一种规则 = 扩 `rules` 数据 + 本文件一种检查(§6 可扩展性)。 * * 本文件不做最终决策合并(那是 `decision.js` 的事),只产出本地这一路的子判定。 * * @typedef {object} Rules * @property {string[]} [words] 敏感词表(运营注入,可改不改代码) * @property {number} [lengthMin] 文本最短长度(含) * @property {number} [lengthMax] 文本最长长度(含) * @property {FormatRule[]} [formatRules] 格式规则(正则 / 内置类型) * @property {Record>} [policies] 二期:按 policy 覆盖(一期走默认) * * @typedef {object} FormatRule * @property {string} id 规则标识,进 reasons 用 * @property {('control-char'|'regex')} [type] 内置 'control-char',或自定义 'regex' * @property {string|RegExp} [pattern] type='regex' 时的匹配源;命中即视为非法 * @property {string} [reason] 命中时的 reason 文案(缺省用 id) * * @typedef {object} RulesCheckResult * @property {'allow'|'flag'|'block'} decision * @property {string[]} hits 命中的敏感词原文 * @property {string[]} reasons 结构化原因码 */ /** * 控制字符(C0 0x00-0x1F 去掉常规 \t \n \r、C1 0x7F-0x9F)—— 注入痕迹/不可见字符的 * 内置检查。用 \u 转义写死,避免源码里出现裸控制字节。 */ // eslint-disable-next-line no-control-regex const CONTROL_CHAR_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/ /** * 解析「一期生效的 rules」:若注入了 `policies[policy]` 则浅覆盖默认字段, * 否则原样返回默认(二期分级/区域在此扩展,一期默认不报错——验收 6)。 * * @param {Rules} [rules] * @param {string} [policy] * @returns {Rules} */ export function resolveRules(rules, policy) { if (!rules || typeof rules !== 'object') return {} if (!policy || !rules.policies || !rules.policies[policy]) return rules return { ...rules, ...rules.policies[policy] } } /** * 敏感词命中(大小写不敏感的包含匹配)。 * @param {string} text * @param {string[]} [words] * @returns {string[]} 命中的词 */ export function findWordHits(text, words) { if (!Array.isArray(words) || words.length === 0) return [] const lower = text.toLowerCase() /** @type {string[]} */ const hits = [] for (const w of words) { if (typeof w !== 'string' || !w) continue if (lower.includes(w.toLowerCase()) && !hits.includes(w)) hits.push(w) } return hits } /** * 格式检查:内置 control-char + 注入式 regex 规则。命中返回 reason 列表。 * @param {string} text * @param {FormatRule[]} [formatRules] * @returns {string[]} */ export function checkFormat(text, formatRules) { /** @type {string[]} */ const reasons = [] // 内置:控制字符 / 不可见注入字符。无需运营配置,默认开启。 if (CONTROL_CHAR_RE.test(text)) reasons.push('format:control-char') if (!Array.isArray(formatRules)) return reasons for (const rule of formatRules) { if (!rule || typeof rule !== 'object') continue const id = rule.id || 'format' const reason = rule.reason || `format:${id}` if (rule.type === 'control-char') { if (CONTROL_CHAR_RE.test(text) && !reasons.includes(reason)) reasons.push(reason) continue } if (rule.pattern !== undefined) { const re = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern) if (re.test(text) && !reasons.includes(reason)) reasons.push(reason) } } return reasons } /** * 跑全部本地规则,给出本地这一路子判定。 * * 判定策略(一期固定,显式可见): * - 敏感词命中 / 长度越界 / 非法格式 → `block`(本地硬红线)。 * - 全部通过 → `allow`。 * (`flag`(软标记)留给 llm 护栏或二期分级使用,一期本地不主动产出 flag, * 避免半成品分级逻辑混入——§5 review 关注点 6。) * * @param {object} args * @param {string} args.text * @param {string} [args.kind] 'user-input' | 'generated' | 'profile' ...(一期不分支,进 reasons 备注) * @param {Rules} args.rules 已解析的规则(调用方可先 resolveRules) * @returns {RulesCheckResult} */ export function runRulesCheck({ text, kind, rules }) { /** @type {string[]} */ const reasons = [] /** @type {string[]} */ let hits = [] if (typeof text !== 'string') { return { decision: 'block', hits: [], reasons: ['format:not-a-string'] } } const r = rules || {} // 长度 const len = text.length if (typeof r.lengthMin === 'number' && len < r.lengthMin) { reasons.push('length:too-short') } if (typeof r.lengthMax === 'number' && len > r.lengthMax) { reasons.push('length:too-long') } // 格式 for (const fr of checkFormat(text, r.formatRules)) reasons.push(fr) // 敏感词 hits = findWordHits(text, r.words) for (const w of hits) reasons.push(`word:${w}`) if (kind) reasons.push(`kind:${kind}`) // 任何实质问题(非 kind 备注)即 block const hasIssue = reasons.some((x) => !x.startsWith('kind:')) return { decision: hasIssue ? 'block' : 'allow', hits, reasons, } }