rules-check.js 5.52 KB
/**
 * 本地规则校验 —— 零依赖纯函数(§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<string, Partial<Rules>>} [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,
  }
}