rules-check.js
5.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
/**
* 本地规则校验 —— 零依赖纯函数(§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,
}
}