plugin-sensitive-filter-xr 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # Xpert Plugin: Sensitive Filter Middleware
2
+
3
+ `@xpert-ai/plugin-sensitive-filter` 为智能体提供输入/输出敏感内容过滤能力。
4
+
5
+ ## 功能说明
6
+
7
+ - `beforeAgent`:检测输入命中,决策 `block / rewrite`
8
+ - `wrapModelCall`:
9
+ - 输入命中 `block`:直接返回拦截提示,不调用模型
10
+ - 输入命中 `rewrite`:先改写最后一条 human 消息,再调用模型
11
+ - 输出命中后再次执行 `block / rewrite`
12
+ - `afterAgent`:空实现(不输出审计日志)
13
+
14
+ ## 规则与优先级
15
+
16
+ - 支持规则类型:`keyword`、`regex`
17
+ - 支持生效范围:`input`、`output`、`both`
18
+ - 冲突优先级:`high > medium`;同级按配置顺序第一条
19
+ - `rewrite` 为整句替换,不做局部替换
20
+ - `normalize=true` 时执行文本标准化(trim + 空白折叠 + 大小写归一)
21
+ - regex 在中间件初始化时预编译,非法 pattern 会直接抛错
22
+
23
+ ## 配置接口
24
+
25
+ ```ts
26
+ type SensitiveRule = {
27
+ id: string;
28
+ pattern: string;
29
+ type: 'keyword' | 'regex';
30
+ scope: 'input' | 'output' | 'both';
31
+ severity: 'high' | 'medium';
32
+ action: 'block' | 'rewrite';
33
+ replacementText?: string;
34
+ };
35
+
36
+ type GeneralPackConfig = {
37
+ enabled?: boolean; // default false
38
+ profile?: 'strict' | 'balanced'; // default balanced
39
+ };
40
+
41
+ type SensitiveFilterConfig = {
42
+ rules?: Array<Partial<SensitiveRule> | null>; // 允许空,支持仅通用规则包
43
+ generalPack?: GeneralPackConfig;
44
+ caseSensitive?: boolean; // default false
45
+ normalize?: boolean; // default true
46
+ };
47
+ ```
48
+
49
+ ## 通用规则包(General Pack)
50
+
51
+ - `enabled=false`:不启用通用规则
52
+ - `enabled=true, profile=balanced`:词库较小,命中后整句替换
53
+ - `enabled=true, profile=strict`:词库更广,命中后直接拦截
54
+
55
+ 说明:通用规则包用于无内建审核能力的本地模型兜底,业务规则仍建议按场景自行配置。
56
+
57
+ ## 开发验证
58
+
59
+ ```bash
60
+ pnpm -C xpertai exec nx build @xpert-ai/plugin-sensitive-filter
61
+ pnpm -C xpertai exec nx test @xpert-ai/plugin-sensitive-filter
62
+ pnpm -C plugin-dev-harness build
63
+ node plugin-dev-harness/dist/index.js --workspace ./xpertai --plugin @xpert-ai/plugin-sensitive-filter
64
+ ```
@@ -0,0 +1,6 @@
1
+ import { z } from 'zod';
2
+ import type { XpertPlugin } from '@xpert-ai/plugin-sdk';
3
+ declare const ConfigSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
4
+ declare const plugin: XpertPlugin<z.infer<typeof ConfigSchema>>;
5
+ export default plugin;
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAexD,QAAA,MAAM,YAAY,gDAChB,CAAC;AAEH,QAAA,MAAM,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CA2BrD,CAAC;AAEF,eAAe,MAAM,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,39 @@
1
+ import { z } from 'zod';
2
+ import { readFileSync } from 'fs';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+ import { SensitiveFilterPlugin } from './lib/sensitive-filter.module.js';
6
+ import { SensitiveFilterIcon } from './lib/types.js';
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+ const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
10
+ const ConfigSchema = z.object({});
11
+ const plugin = {
12
+ meta: {
13
+ name: packageJson.name,
14
+ version: packageJson.version,
15
+ category: 'middleware',
16
+ icon: {
17
+ type: 'svg',
18
+ value: SensitiveFilterIcon,
19
+ },
20
+ displayName: 'Sensitive Filter Middleware',
21
+ description: 'Filter sensitive content for agent input and model output with business rules and optional general pack.',
22
+ keywords: ['agent', 'middleware', 'sensitive', 'security', 'compliance'],
23
+ author: 'XpertAI Team',
24
+ },
25
+ config: {
26
+ schema: ConfigSchema,
27
+ },
28
+ register(ctx) {
29
+ ctx.logger.log('register sensitive filter middleware plugin');
30
+ return { module: SensitiveFilterPlugin, global: true };
31
+ },
32
+ async onStart(ctx) {
33
+ ctx.logger.log('sensitive filter middleware plugin started');
34
+ },
35
+ async onStop(ctx) {
36
+ ctx.logger.log('sensitive filter middleware plugin stopped');
37
+ },
38
+ };
39
+ export default plugin;
@@ -0,0 +1,7 @@
1
+ import { IOnPluginBootstrap, IOnPluginDestroy } from '@xpert-ai/plugin-sdk';
2
+ export declare class SensitiveFilterPlugin implements IOnPluginBootstrap, IOnPluginDestroy {
3
+ private logEnabled;
4
+ onPluginBootstrap(): void | Promise<void>;
5
+ onPluginDestroy(): void | Promise<void>;
6
+ }
7
+ //# sourceMappingURL=sensitive-filter.module.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sensitive-filter.module.d.ts","sourceRoot":"","sources":["../../src/lib/sensitive-filter.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAI/F,qBAIa,qBAAsB,YAAW,kBAAkB,EAAE,gBAAgB;IAChF,OAAO,CAAC,UAAU,CAAQ;IAE1B,iBAAiB,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAMzC,eAAe,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;CAKxC"}
@@ -0,0 +1,27 @@
1
+ var SensitiveFilterPlugin_1;
2
+ import { __decorate } from "tslib";
3
+ import { XpertServerPlugin } from '@xpert-ai/plugin-sdk';
4
+ import chalk from 'chalk';
5
+ import { SensitiveFilterMiddleware } from './sensitiveFilter.js';
6
+ let SensitiveFilterPlugin = SensitiveFilterPlugin_1 = class SensitiveFilterPlugin {
7
+ constructor() {
8
+ this.logEnabled = true;
9
+ }
10
+ onPluginBootstrap() {
11
+ if (this.logEnabled) {
12
+ console.log(chalk.green(`${SensitiveFilterPlugin_1.name} is being bootstrapped...`));
13
+ }
14
+ }
15
+ onPluginDestroy() {
16
+ if (this.logEnabled) {
17
+ console.log(chalk.green(`${SensitiveFilterPlugin_1.name} is being destroyed...`));
18
+ }
19
+ }
20
+ };
21
+ SensitiveFilterPlugin = SensitiveFilterPlugin_1 = __decorate([
22
+ XpertServerPlugin({
23
+ imports: [],
24
+ providers: [SensitiveFilterMiddleware]
25
+ })
26
+ ], SensitiveFilterPlugin);
27
+ export { SensitiveFilterPlugin };
@@ -0,0 +1,9 @@
1
+ import { TAgentMiddlewareMeta } from '@metad/contracts';
2
+ import { AgentMiddleware, IAgentMiddlewareContext, IAgentMiddlewareStrategy, PromiseOrValue } from '@xpert-ai/plugin-sdk';
3
+ import { SensitiveFilterConfig } from './types.js';
4
+ export declare class SensitiveFilterMiddleware implements IAgentMiddlewareStrategy<SensitiveFilterConfig> {
5
+ readonly meta: TAgentMiddlewareMeta;
6
+ createMiddleware(options: SensitiveFilterConfig, _context: IAgentMiddlewareContext): PromiseOrValue<AgentMiddleware>;
7
+ }
8
+ export type { SensitiveFilterConfig };
9
+ //# sourceMappingURL=sensitiveFilter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sensitiveFilter.d.ts","sourceRoot":"","sources":["../../src/lib/sensitiveFilter.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAA;AAEvD,OAAO,EACL,eAAe,EAEf,uBAAuB,EACvB,wBAAwB,EACxB,cAAc,EACf,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAGL,qBAAqB,EAKtB,MAAM,YAAY,CAAA;AAuQnB,qBAEa,yBAA0B,YAAW,wBAAwB,CAAC,qBAAqB,CAAC;IAC/F,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CA4IlC;IAED,gBAAgB,CACd,OAAO,EAAE,qBAAqB,EAC9B,QAAQ,EAAE,uBAAuB,GAChC,cAAc,CAAC,eAAe,CAAC;CA4GnC;AAED,YAAY,EAAE,qBAAqB,EAAE,CAAA"}
@@ -0,0 +1,444 @@
1
+ import { __decorate } from "tslib";
2
+ import { z as z4 } from 'zod/v4';
3
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
4
+ import { Injectable } from '@nestjs/common';
5
+ import { AgentMiddlewareStrategy, } from '@xpert-ai/plugin-sdk';
6
+ import { SensitiveFilterIcon, resolveGeneralPackRules, sensitiveFilterConfigSchema, } from './types.js';
7
+ const SENSITIVE_FILTER_MIDDLEWARE_NAME = 'SensitiveFilterMiddleware';
8
+ const DEFAULT_INPUT_BLOCK_MESSAGE = '输入内容触发敏感策略,已拦截。';
9
+ const DEFAULT_OUTPUT_BLOCK_MESSAGE = '输出内容触发敏感策略,已拦截。';
10
+ const DEFAULT_REWRITE_TEXT = '[已过滤]';
11
+ function isRecord(value) {
12
+ return typeof value === 'object' && value !== null;
13
+ }
14
+ function toNonEmptyString(value) {
15
+ if (typeof value !== 'string') {
16
+ return null;
17
+ }
18
+ const trimmed = value.trim();
19
+ return trimmed ? trimmed : null;
20
+ }
21
+ function normalizeForMatching(text, normalize, caseSensitive) {
22
+ const source = normalize ? text.trim().replace(/\s+/g, ' ') : text;
23
+ return caseSensitive ? source : source.toLowerCase();
24
+ }
25
+ function extractPrimitiveText(value) {
26
+ if (typeof value === 'string') {
27
+ return value;
28
+ }
29
+ if (typeof value === 'number' || typeof value === 'boolean') {
30
+ return String(value);
31
+ }
32
+ if (Array.isArray(value)) {
33
+ return value
34
+ .map((item) => {
35
+ if (typeof item === 'string') {
36
+ return item;
37
+ }
38
+ if (isRecord(item) && typeof item['text'] === 'string') {
39
+ return item['text'];
40
+ }
41
+ return '';
42
+ })
43
+ .join('');
44
+ }
45
+ return '';
46
+ }
47
+ function extractInputText(state, runtime) {
48
+ const runtimeState = runtime?.state;
49
+ const runtimeHuman = isRecord(runtimeState?.['human']) ? runtimeState['human'] : null;
50
+ const stateHuman = isRecord(state?.['human']) ? state['human'] : null;
51
+ const candidates = [
52
+ runtimeHuman?.['input'],
53
+ runtimeState?.['input'],
54
+ stateHuman?.['input'],
55
+ state?.['input'],
56
+ ];
57
+ for (const candidate of candidates) {
58
+ const text = extractPrimitiveText(candidate).trim();
59
+ if (text) {
60
+ return text;
61
+ }
62
+ }
63
+ return '';
64
+ }
65
+ function escapeRegExp(value) {
66
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
67
+ }
68
+ function getSeverityWeight(severity) {
69
+ return severity === 'high' ? 2 : 1;
70
+ }
71
+ function pickWinningRule(matches) {
72
+ if (matches.length === 0) {
73
+ return null;
74
+ }
75
+ let winner = matches[0];
76
+ for (const current of matches.slice(1)) {
77
+ const currentWeight = getSeverityWeight(current.severity);
78
+ const winnerWeight = getSeverityWeight(winner.severity);
79
+ if (currentWeight > winnerWeight) {
80
+ winner = current;
81
+ continue;
82
+ }
83
+ if (currentWeight === winnerWeight && current.index < winner.index) {
84
+ winner = current;
85
+ }
86
+ }
87
+ return winner;
88
+ }
89
+ function rewriteTextByRule(_source, rule, _caseSensitive) {
90
+ const replacement = rule.replacementText?.trim() || DEFAULT_REWRITE_TEXT;
91
+ // rewrite 按整句替换,避免局部替换造成语义残留
92
+ return replacement;
93
+ }
94
+ function findMatches(text, phase, rules, normalize, caseSensitive) {
95
+ if (!text) {
96
+ return [];
97
+ }
98
+ const matchTarget = normalizeForMatching(text, normalize, caseSensitive);
99
+ return rules.filter((rule) => {
100
+ if (rule.scope !== 'both' && rule.scope !== phase) {
101
+ return false;
102
+ }
103
+ if (rule.type === 'keyword') {
104
+ return matchTarget.includes(rule.normalizedPattern);
105
+ }
106
+ return Boolean(rule.matchRegex?.test(matchTarget));
107
+ });
108
+ }
109
+ function extractModelResponseText(response) {
110
+ if (typeof response === 'string') {
111
+ return response;
112
+ }
113
+ if (isRecord(response)) {
114
+ return extractPrimitiveText(response['content']);
115
+ }
116
+ return '';
117
+ }
118
+ function replaceModelResponseText(response, text) {
119
+ if (isRecord(response) && 'content' in response) {
120
+ response['content'] = text;
121
+ return response;
122
+ }
123
+ return new AIMessage(text);
124
+ }
125
+ function rewriteModelRequestInput(request, rewrittenText) {
126
+ if (!Array.isArray(request?.messages) || request.messages.length === 0) {
127
+ return request;
128
+ }
129
+ const messages = [...request.messages];
130
+ for (let i = messages.length - 1; i >= 0; i--) {
131
+ const message = messages[i];
132
+ const messageType = typeof message?._getType === 'function' ? message._getType() : message?.type;
133
+ if (messageType !== 'human') {
134
+ continue;
135
+ }
136
+ const content = message?.content;
137
+ if (typeof content === 'string') {
138
+ messages[i] = new HumanMessage(rewrittenText);
139
+ return { ...request, messages };
140
+ }
141
+ if (Array.isArray(content)) {
142
+ let replaced = false;
143
+ const nextContent = content.map((part) => {
144
+ if (!replaced && isRecord(part) && part['type'] === 'text') {
145
+ replaced = true;
146
+ return {
147
+ ...part,
148
+ text: rewrittenText,
149
+ };
150
+ }
151
+ return part;
152
+ });
153
+ if (!replaced) {
154
+ nextContent.push({
155
+ type: 'text',
156
+ text: rewrittenText,
157
+ });
158
+ }
159
+ messages[i] = new HumanMessage({ content: nextContent });
160
+ return { ...request, messages };
161
+ }
162
+ messages[i] = new HumanMessage(rewrittenText);
163
+ return { ...request, messages };
164
+ }
165
+ return request;
166
+ }
167
+ function normalizeRuleDrafts(input) {
168
+ const rules = [];
169
+ for (const draft of input) {
170
+ if (!isRecord(draft)) {
171
+ continue;
172
+ }
173
+ const id = toNonEmptyString(draft['id']);
174
+ const pattern = toNonEmptyString(draft['pattern']);
175
+ const type = toNonEmptyString(draft['type']);
176
+ const scope = toNonEmptyString(draft['scope']);
177
+ const severity = toNonEmptyString(draft['severity']);
178
+ const action = toNonEmptyString(draft['action']);
179
+ const replacementText = toNonEmptyString(draft['replacementText']) ?? undefined;
180
+ const hasAnyValue = Boolean(id || pattern || type || scope || severity || action || replacementText);
181
+ if (!hasAnyValue) {
182
+ continue;
183
+ }
184
+ if (!id || !pattern || !type || !scope || !severity || !action) {
185
+ continue;
186
+ }
187
+ if (!['keyword', 'regex'].includes(type)) {
188
+ continue;
189
+ }
190
+ if (!['input', 'output', 'both'].includes(scope)) {
191
+ continue;
192
+ }
193
+ if (!['high', 'medium'].includes(severity)) {
194
+ continue;
195
+ }
196
+ if (!['block', 'rewrite'].includes(action)) {
197
+ continue;
198
+ }
199
+ rules.push({
200
+ id,
201
+ pattern,
202
+ type,
203
+ scope,
204
+ severity,
205
+ action,
206
+ replacementText,
207
+ });
208
+ }
209
+ return rules;
210
+ }
211
+ let SensitiveFilterMiddleware = class SensitiveFilterMiddleware {
212
+ constructor() {
213
+ this.meta = {
214
+ name: SENSITIVE_FILTER_MIDDLEWARE_NAME,
215
+ label: {
216
+ en_US: 'Sensitive Filter Middleware',
217
+ zh_Hans: '敏感内容过滤中间件',
218
+ },
219
+ description: {
220
+ en_US: 'Filter sensitive content before input and after output using business rules and optional local general lexicon.',
221
+ zh_Hans: '基于业务规则进行输入/输出过滤,并可选启用本地开源通用词库(中英双语)作为兜底。',
222
+ },
223
+ icon: {
224
+ type: 'svg',
225
+ value: SensitiveFilterIcon,
226
+ },
227
+ configSchema: {
228
+ type: 'object',
229
+ properties: {
230
+ rules: {
231
+ type: 'array',
232
+ title: {
233
+ en_US: 'Business Rules',
234
+ zh_Hans: '业务规则',
235
+ },
236
+ description: {
237
+ en_US: 'Add at least one complete business rule. If you only need baseline protection, enable General Pack below.',
238
+ zh_Hans: '建议至少添加一条完整业务规则;若仅需基础兜底,可直接启用下方“通用规则包”。',
239
+ },
240
+ items: {
241
+ type: 'object',
242
+ properties: {
243
+ id: {
244
+ type: 'string',
245
+ title: { en_US: 'Rule ID', zh_Hans: '规则标识' },
246
+ },
247
+ pattern: {
248
+ type: 'string',
249
+ title: { en_US: 'Pattern', zh_Hans: '匹配内容' },
250
+ },
251
+ type: {
252
+ type: 'string',
253
+ title: { en_US: 'Type', zh_Hans: '匹配类型' },
254
+ enum: ['keyword', 'regex'],
255
+ 'x-ui': {
256
+ enumLabels: {
257
+ keyword: { en_US: 'Keyword', zh_Hans: '关键词' },
258
+ regex: { en_US: 'Regex', zh_Hans: '正则表达式' },
259
+ },
260
+ },
261
+ },
262
+ scope: {
263
+ type: 'string',
264
+ title: { en_US: 'Scope', zh_Hans: '生效范围' },
265
+ enum: ['input', 'output', 'both'],
266
+ 'x-ui': {
267
+ enumLabels: {
268
+ input: { en_US: 'Input', zh_Hans: '仅输入' },
269
+ output: { en_US: 'Output', zh_Hans: '仅输出' },
270
+ both: { en_US: 'Both', zh_Hans: '输入和输出' },
271
+ },
272
+ },
273
+ },
274
+ severity: {
275
+ type: 'string',
276
+ title: { en_US: 'Severity', zh_Hans: '优先级' },
277
+ enum: ['high', 'medium'],
278
+ 'x-ui': {
279
+ enumLabels: {
280
+ high: { en_US: 'High', zh_Hans: '高' },
281
+ medium: { en_US: 'Medium', zh_Hans: '中' },
282
+ },
283
+ },
284
+ },
285
+ action: {
286
+ type: 'string',
287
+ title: { en_US: 'Action', zh_Hans: '命中动作' },
288
+ enum: ['block', 'rewrite'],
289
+ 'x-ui': {
290
+ enumLabels: {
291
+ block: { en_US: 'Block', zh_Hans: '拦截' },
292
+ rewrite: { en_US: 'Rewrite', zh_Hans: '整句替换' },
293
+ },
294
+ },
295
+ },
296
+ replacementText: {
297
+ type: 'string',
298
+ title: { en_US: 'Replacement Text', zh_Hans: '替换文本(可选)' },
299
+ },
300
+ },
301
+ },
302
+ },
303
+ generalPack: {
304
+ type: 'object',
305
+ title: {
306
+ en_US: 'General Pack',
307
+ zh_Hans: '通用规则包(本地开源词库)',
308
+ },
309
+ description: {
310
+ en_US: 'For local models without built-in moderation. Uses local bilingual (ZH/EN) open-source lexicon.',
311
+ zh_Hans: '用于没有内置安全过滤的本地模型兜底;采用本地中英双语开源词库。',
312
+ },
313
+ properties: {
314
+ enabled: {
315
+ type: 'boolean',
316
+ default: false,
317
+ title: { en_US: 'Enable', zh_Hans: '启用通用规则包' },
318
+ },
319
+ profile: {
320
+ type: 'string',
321
+ title: { en_US: 'Profile', zh_Hans: '策略档位' },
322
+ enum: ['strict', 'balanced'],
323
+ 'x-ui': {
324
+ enumLabels: {
325
+ strict: { en_US: 'Strict', zh_Hans: '严格' },
326
+ balanced: { en_US: 'Balanced', zh_Hans: '平衡' },
327
+ },
328
+ tooltip: {
329
+ en_US: 'Strict: broader lexicon + block on hit. Balanced: base lexicon + sentence rewrite on hit.',
330
+ zh_Hans: '严格:词库范围更大,命中后直接拦截。平衡:词库范围较小,命中后整句替换再继续。',
331
+ },
332
+ },
333
+ default: 'balanced',
334
+ },
335
+ },
336
+ },
337
+ caseSensitive: {
338
+ type: 'boolean',
339
+ default: false,
340
+ title: { en_US: 'Case Sensitive', zh_Hans: '区分大小写' },
341
+ },
342
+ normalize: {
343
+ type: 'boolean',
344
+ default: true,
345
+ title: { en_US: 'Normalize Text', zh_Hans: '文本标准化' },
346
+ },
347
+ },
348
+ },
349
+ };
350
+ }
351
+ createMiddleware(options, _context) {
352
+ const parsed = sensitiveFilterConfigSchema.safeParse(options ?? {});
353
+ if (!parsed.success) {
354
+ throw new Error(`Invalid sensitive filter middleware options: ${z4.prettifyError(parsed.error)}`);
355
+ }
356
+ const config = parsed.data;
357
+ const caseSensitive = config.caseSensitive ?? false;
358
+ const normalize = config.normalize ?? true;
359
+ const customRules = normalizeRuleDrafts(config.rules ?? []);
360
+ const generalRules = resolveGeneralPackRules(config.generalPack);
361
+ const allRules = [...customRules, ...generalRules];
362
+ const compiledRules = allRules.map((rule, index) => {
363
+ const normalizedPattern = rule.type === 'keyword' ? normalizeForMatching(rule.pattern, normalize, caseSensitive) : rule.pattern;
364
+ if (rule.type === 'regex') {
365
+ try {
366
+ return {
367
+ ...rule,
368
+ index,
369
+ normalizedPattern,
370
+ matchRegex: new RegExp(rule.pattern, caseSensitive ? '' : 'i'),
371
+ rewriteRegex: new RegExp(rule.pattern, caseSensitive ? 'g' : 'gi'),
372
+ };
373
+ }
374
+ catch (error) {
375
+ const message = error instanceof Error ? error.message : String(error);
376
+ throw new Error(`Invalid regex pattern in rule '${rule.id}': ${message}`);
377
+ }
378
+ }
379
+ return {
380
+ ...rule,
381
+ index,
382
+ normalizedPattern,
383
+ };
384
+ });
385
+ let inputBlockedMessage = null;
386
+ let pendingInputRewrite = null;
387
+ return {
388
+ name: SENSITIVE_FILTER_MIDDLEWARE_NAME,
389
+ beforeAgent: async (state, runtime) => {
390
+ const safeState = state ?? {};
391
+ const safeRuntime = runtime ?? {};
392
+ inputBlockedMessage = null;
393
+ pendingInputRewrite = null;
394
+ if (compiledRules.length === 0) {
395
+ return undefined;
396
+ }
397
+ const inputText = extractInputText(safeState, safeRuntime);
398
+ const inputMatches = findMatches(inputText, 'input', compiledRules, normalize, caseSensitive);
399
+ const winner = pickWinningRule(inputMatches);
400
+ if (!winner) {
401
+ return undefined;
402
+ }
403
+ if (winner.action === 'block') {
404
+ inputBlockedMessage = winner.replacementText?.trim() || DEFAULT_INPUT_BLOCK_MESSAGE;
405
+ return undefined;
406
+ }
407
+ const rewrittenInput = rewriteTextByRule(inputText, winner, caseSensitive);
408
+ pendingInputRewrite = rewrittenInput;
409
+ return undefined;
410
+ },
411
+ wrapModelCall: async (request, handler) => {
412
+ if (compiledRules.length === 0) {
413
+ return handler(request);
414
+ }
415
+ if (inputBlockedMessage) {
416
+ return new AIMessage(inputBlockedMessage);
417
+ }
418
+ const modelRequest = pendingInputRewrite ? rewriteModelRequestInput(request, pendingInputRewrite) : request;
419
+ pendingInputRewrite = null;
420
+ const response = await handler(modelRequest);
421
+ const outputText = extractModelResponseText(response);
422
+ const outputMatches = findMatches(outputText, 'output', compiledRules, normalize, caseSensitive);
423
+ const winner = pickWinningRule(outputMatches);
424
+ if (!winner) {
425
+ return response;
426
+ }
427
+ if (winner.action === 'block') {
428
+ const blockedOutput = winner.replacementText?.trim() || DEFAULT_OUTPUT_BLOCK_MESSAGE;
429
+ return replaceModelResponseText(response, blockedOutput);
430
+ }
431
+ const rewrittenOutput = rewriteTextByRule(outputText, winner, caseSensitive);
432
+ return replaceModelResponseText(response, rewrittenOutput);
433
+ },
434
+ afterAgent: async () => {
435
+ return undefined;
436
+ },
437
+ };
438
+ }
439
+ };
440
+ SensitiveFilterMiddleware = __decorate([
441
+ Injectable(),
442
+ AgentMiddlewareStrategy(SENSITIVE_FILTER_MIDDLEWARE_NAME)
443
+ ], SensitiveFilterMiddleware);
444
+ export { SensitiveFilterMiddleware };
@@ -0,0 +1,100 @@
1
+ import { z } from 'zod/v3';
2
+ export type SensitiveRule = {
3
+ id: string;
4
+ pattern: string;
5
+ type: 'keyword' | 'regex';
6
+ scope: 'input' | 'output' | 'both';
7
+ severity: 'high' | 'medium';
8
+ action: 'block' | 'rewrite';
9
+ replacementText?: string;
10
+ };
11
+ export type GeneralPackConfig = {
12
+ enabled?: boolean;
13
+ profile?: 'strict' | 'balanced';
14
+ };
15
+ export type SensitiveFilterConfig = {
16
+ rules?: Array<Partial<SensitiveRule> | null>;
17
+ generalPack?: GeneralPackConfig;
18
+ caseSensitive?: boolean;
19
+ normalize?: boolean;
20
+ };
21
+ export type CompiledSensitiveRule = SensitiveRule & {
22
+ index: number;
23
+ normalizedPattern: string;
24
+ matchRegex?: RegExp;
25
+ rewriteRegex?: RegExp;
26
+ };
27
+ export declare const SensitiveFilterIcon = "<svg width=\"800px\" height=\"800px\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M12 2l7 3v6c0 5.2-3.3 9.9-7 11-3.7-1.1-7-5.8-7-11V5l7-3zm0 2.1L7 6v5c0 3.9 2.3 7.8 5 8.9 2.7-1.1 5-5 5-8.9V6l-5-1.9zM8.8 12.6l1.4-1.4 1.8 1.8 3.8-3.8 1.4 1.4-5.2 5.2-3.2-3.2z\"/></svg>";
28
+ export declare const sensitiveFilterConfigSchema: z.ZodObject<{
29
+ rules: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodNullable<z.ZodObject<{
30
+ id: z.ZodNullable<z.ZodOptional<z.ZodString>>;
31
+ pattern: z.ZodNullable<z.ZodOptional<z.ZodString>>;
32
+ type: z.ZodNullable<z.ZodOptional<z.ZodEnum<["keyword", "regex"]>>>;
33
+ scope: z.ZodNullable<z.ZodOptional<z.ZodEnum<["input", "output", "both"]>>>;
34
+ severity: z.ZodNullable<z.ZodOptional<z.ZodEnum<["high", "medium"]>>>;
35
+ action: z.ZodNullable<z.ZodOptional<z.ZodEnum<["block", "rewrite"]>>>;
36
+ replacementText: z.ZodNullable<z.ZodOptional<z.ZodString>>;
37
+ }, "strip", z.ZodTypeAny, {
38
+ id?: string;
39
+ pattern?: string;
40
+ type?: "keyword" | "regex";
41
+ scope?: "input" | "output" | "both";
42
+ severity?: "high" | "medium";
43
+ action?: "block" | "rewrite";
44
+ replacementText?: string;
45
+ }, {
46
+ id?: string;
47
+ pattern?: string;
48
+ type?: "keyword" | "regex";
49
+ scope?: "input" | "output" | "both";
50
+ severity?: "high" | "medium";
51
+ action?: "block" | "rewrite";
52
+ replacementText?: string;
53
+ }>>, "many">>>;
54
+ generalPack: z.ZodOptional<z.ZodObject<{
55
+ enabled: z.ZodDefault<z.ZodBoolean>;
56
+ profile: z.ZodOptional<z.ZodDefault<z.ZodEnum<["strict", "balanced"]>>>;
57
+ }, "strip", z.ZodTypeAny, {
58
+ enabled?: boolean;
59
+ profile?: "strict" | "balanced";
60
+ }, {
61
+ enabled?: boolean;
62
+ profile?: "strict" | "balanced";
63
+ }>>;
64
+ caseSensitive: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
65
+ normalize: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
66
+ }, "strip", z.ZodTypeAny, {
67
+ rules?: {
68
+ id?: string;
69
+ pattern?: string;
70
+ type?: "keyword" | "regex";
71
+ scope?: "input" | "output" | "both";
72
+ severity?: "high" | "medium";
73
+ action?: "block" | "rewrite";
74
+ replacementText?: string;
75
+ }[];
76
+ generalPack?: {
77
+ enabled?: boolean;
78
+ profile?: "strict" | "balanced";
79
+ };
80
+ caseSensitive?: boolean;
81
+ normalize?: boolean;
82
+ }, {
83
+ rules?: {
84
+ id?: string;
85
+ pattern?: string;
86
+ type?: "keyword" | "regex";
87
+ scope?: "input" | "output" | "both";
88
+ severity?: "high" | "medium";
89
+ action?: "block" | "rewrite";
90
+ replacementText?: string;
91
+ }[];
92
+ generalPack?: {
93
+ enabled?: boolean;
94
+ profile?: "strict" | "balanced";
95
+ };
96
+ caseSensitive?: boolean;
97
+ normalize?: boolean;
98
+ }>;
99
+ export declare function resolveGeneralPackRules(config?: GeneralPackConfig): SensitiveRule[];
100
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lib/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,QAAQ,CAAA;AAE1B,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,SAAS,GAAG,OAAO,CAAA;IACzB,KAAK,EAAE,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAA;IAClC,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAA;IAC3B,MAAM,EAAE,OAAO,GAAG,SAAS,CAAA;IAC3B,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,OAAO,CAAC,EAAE,QAAQ,GAAG,UAAU,CAAA;CAChC,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,KAAK,CAAC,EAAE,KAAK,CAAC,OAAO,CAAC,aAAa,CAAC,GAAG,IAAI,CAAC,CAAA;IAC5C,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG,aAAa,GAAG;IAClD,KAAK,EAAE,MAAM,CAAA;IACb,iBAAiB,EAAE,MAAM,CAAA;IACzB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,CAAA;AAED,eAAO,MAAM,mBAAmB,wSAA8R,CAAA;AAmC9T,eAAO,MAAM,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAKtC,CAAA;AAYF,wBAAgB,uBAAuB,CAAC,MAAM,CAAC,EAAE,iBAAiB,GAAG,aAAa,EAAE,CA6CnF"}
@@ -0,0 +1,86 @@
1
+ import { z } from 'zod/v3';
2
+ export const SensitiveFilterIcon = `<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2l7 3v6c0 5.2-3.3 9.9-7 11-3.7-1.1-7-5.8-7-11V5l7-3zm0 2.1L7 6v5c0 3.9 2.3 7.8 5 8.9 2.7-1.1 5-5 5-8.9V6l-5-1.9zM8.8 12.6l1.4-1.4 1.8 1.8 3.8-3.8 1.4 1.4-5.2 5.2-3.2-3.2z"/></svg>`;
3
+ /**
4
+ * 本地通用词库来源(中英双语,V1 子集):
5
+ * - English: LDNOOBW
6
+ * - Chinese: ToolGood.Words 社区词表
7
+ */
8
+ const OPEN_SOURCE_LEXICON = {
9
+ en: {
10
+ balanced: ['fuck', 'shit', 'bitch', 'asshole', 'bastard', 'motherfucker', 'dumbass', 'cunt'],
11
+ strictExtra: ['slut', 'whore', 'retard', 'nigger', 'faggot'],
12
+ },
13
+ zh: {
14
+ balanced: ['傻逼', '操你妈', '他妈的', '王八蛋', '滚蛋', '去死', '脑残', '妈的'],
15
+ strictExtra: ['强奸', '炸弹', '杀人', '自杀', '毒品'],
16
+ },
17
+ };
18
+ const sensitiveRuleDraftSchema = z
19
+ .object({
20
+ id: z.string().optional().nullable(),
21
+ pattern: z.string().optional().nullable(),
22
+ type: z.enum(['keyword', 'regex']).optional().nullable(),
23
+ scope: z.enum(['input', 'output', 'both']).optional().nullable(),
24
+ severity: z.enum(['high', 'medium']).optional().nullable(),
25
+ action: z.enum(['block', 'rewrite']).optional().nullable(),
26
+ replacementText: z.string().optional().nullable(),
27
+ })
28
+ .nullable();
29
+ const generalPackSchema = z.object({
30
+ enabled: z.boolean().default(false),
31
+ profile: z.enum(['strict', 'balanced']).default('balanced').optional(),
32
+ });
33
+ export const sensitiveFilterConfigSchema = z.object({
34
+ rules: z.array(sensitiveRuleDraftSchema).optional().default([]),
35
+ generalPack: generalPackSchema.optional(),
36
+ caseSensitive: z.boolean().optional().default(false),
37
+ normalize: z.boolean().optional().default(true),
38
+ });
39
+ function escapeRegExp(value) {
40
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
+ }
42
+ function buildLexiconRegex(words) {
43
+ const unique = Array.from(new Set(words.map((w) => w.trim()).filter(Boolean)));
44
+ const escaped = unique.map((w) => escapeRegExp(w));
45
+ return escaped.length ? `(?:${escaped.join('|')})` : '';
46
+ }
47
+ export function resolveGeneralPackRules(config) {
48
+ if (!config?.enabled) {
49
+ return [];
50
+ }
51
+ const profile = config.profile ?? 'balanced';
52
+ const enWords = [
53
+ ...OPEN_SOURCE_LEXICON.en.balanced,
54
+ ...(profile === 'strict' ? OPEN_SOURCE_LEXICON.en.strictExtra : []),
55
+ ];
56
+ const zhWords = [
57
+ ...OPEN_SOURCE_LEXICON.zh.balanced,
58
+ ...(profile === 'strict' ? OPEN_SOURCE_LEXICON.zh.strictExtra : []),
59
+ ];
60
+ const enPattern = buildLexiconRegex(enWords);
61
+ const zhPattern = buildLexiconRegex(zhWords);
62
+ const action = profile === 'strict' ? 'block' : 'rewrite';
63
+ const severity = profile === 'strict' ? 'high' : 'medium';
64
+ const replacementText = profile === 'strict' ? '内容触发通用敏感词策略,已拦截。' : '[通用敏感词已过滤]';
65
+ const rules = [
66
+ {
67
+ id: 'general-open-lexicon-en',
68
+ pattern: enPattern,
69
+ type: 'regex',
70
+ scope: 'both',
71
+ severity,
72
+ action,
73
+ replacementText,
74
+ },
75
+ {
76
+ id: 'general-open-lexicon-zh',
77
+ pattern: zhPattern,
78
+ type: 'regex',
79
+ scope: 'both',
80
+ severity,
81
+ action,
82
+ replacementText,
83
+ },
84
+ ];
85
+ return rules.filter((rule) => Boolean(rule.pattern));
86
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "plugin-sensitive-filter-xr",
3
+ "version": "0.0.1",
4
+ "author": {
5
+ "name": "XpertAI",
6
+ "url": "https://xpertai.cn"
7
+ },
8
+ "license": "AGPL-3.0",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/xpert-ai/xpert-plugins.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/xpert-ai/xpert-plugins/issues"
15
+ },
16
+ "type": "module",
17
+ "main": "./dist/index.js",
18
+ "module": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ "./package.json": "./package.json",
22
+ ".": {
23
+ "@xpert-plugins-starter/source": "./src/index.ts",
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "!**/*.tsbuildinfo"
32
+ ],
33
+ "dependencies": {
34
+ "tslib": "^2.3.0"
35
+ },
36
+ "peerDependencies": {
37
+ "zod": "3.25.67",
38
+ "@xpert-ai/plugin-sdk": "^3.7.0",
39
+ "chalk": "4.1.2",
40
+ "@langchain/core": "0.3.72",
41
+ "@nestjs/common": "^11.1.6",
42
+ "@metad/contracts": "^3.7.0"
43
+ }
44
+ }