dsh-mask 0.1.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.
@@ -0,0 +1,54 @@
1
+ # dsh-mask bundle patch: mount the mask plugin.
2
+ #
3
+ # Every key below is a Config field (Schemastery schema); invalid values fail
4
+ # the profile load loudly. See README.md "Configuration" for the full table.
5
+ - insert:
6
+ - id: storage
7
+ name: '@deepseek-ai/dsh-storage'
8
+
9
+ - id: storage-json
10
+ name: '@deepseek-ai/dsh-storage-json'
11
+ config:
12
+ root: !!js dshHomePath('storages')
13
+
14
+ - id: storage-domain
15
+ name: '@deepseek-ai/dsh-storage-domain'
16
+ config:
17
+ backend: json
18
+
19
+ - id: mask
20
+ name: dsh-mask
21
+ config:
22
+ # Master switch; false unregisters the pre-step listener, the /mask
23
+ # command, and the mask_test tool.
24
+ enabled: true
25
+ # Detection mode. Only 'regex' is implemented (zero-dependency pure
26
+ # regex). 'regex+ner' is reserved for an external name/address
27
+ # recognizer and fails loud at load until one is wired in.
28
+ mode: regex
29
+ # Which entity types to mask. 'phone', 'email', 'id-card',
30
+ # 'bank-card', and 'key' are regex-capable; 'ip' is regex-capable but
31
+ # opt-in (frequent false positives in technical context); 'person' and
32
+ # 'address' require NER (mode: regex+ner) and fail loud here.
33
+ entities:
34
+ - phone
35
+ - email
36
+ - id-card
37
+ - bank-card
38
+ - key
39
+ # Masking surface. Only 'messages' (mask agent/pre-step messages before
40
+ # they enter the model) is implemented; 'tools' (mask tool arguments) is
41
+ # reserved and fails loud at load.
42
+ scope: messages
43
+ # Surface switches: register the /mask command and the mask_test tool.
44
+ registerCommand: true
45
+ registerTools: true
46
+ # Persist the placeholder->original restore table to the controlled
47
+ # dsh_mask storage domain (false = memory only, lost on restart).
48
+ persistRestoreTable: true
49
+ # Per-session cap on restore entries; the oldest placeholders are
50
+ # evicted first (their responses can no longer be restored).
51
+ maxRestoreEntriesPerSession: 500
52
+ # Cap on in-memory session strippers; least-recently-used sessions are
53
+ # evicted (mapping reloads from the storage domain on demand).
54
+ maxSessions: 1000
package/index.mjs ADDED
@@ -0,0 +1,379 @@
1
+ // index.mjs — dsh-mask 插件入口(唯一 host 面文件)。
2
+ //
3
+ // 功能:模型边界的 PII 匿名化-恢复中间件。
4
+ // - 请求前遮罩:监听 agent/pre-step(waterfall,直通 next()),把进入模型的
5
+ // UserMessage 文本块里的 PII(电话/邮箱/身份证/银行卡/密钥/IP 可配)替换为
6
+ // `<LABEL_N>` 占位符;遮罩后消息才作为 user/message 落盘 → 模型可见内容可自
7
+ // 日志重建(占位符形式),原文绝不进会话日志。
8
+ // - 恢复表:占位符→原文映射只存内存 + 受控 storageDomain(dsh_mask/restore),
9
+ // 绝不进会话日志明文;/mask restore 与恢复 seam 按需回载。
10
+ // - 审计:mask/applied 会话事件只记"替换了 N 处 + 类型分布"(log-only,自适应
11
+ // 门),不记明文与映射。
12
+ // - 表面:/mask 命令(status|on|off|restore|help)与 mask_test 工具(试跑一段
13
+ // 文本看替换效果,绝不回显原文)。
14
+ //
15
+ // 只消费公开服务:commands/storageDomain(inject 声明),tools 经 ctx.inject
16
+ // 可选注册;lib/ 零 DSH 依赖,服务只在边界接线。
17
+
18
+ import { Context } from '@deepseek-ai/cordis'
19
+ import Schema from '@deepseek-ai/schemastery'
20
+ import SessionStore, { KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session'
21
+ import { defineTool } from '@deepseek-ai/dsh-tools'
22
+ import {
23
+ COMMAND_NAME,
24
+ DEFAULTS,
25
+ ENTITY_NAMES,
26
+ LIMITS,
27
+ MODES,
28
+ NER_ENTITIES,
29
+ PLUGIN_NAME,
30
+ REGEX_ENTITIES,
31
+ SCOPES,
32
+ SESSION_EVENTS,
33
+ TOOL_MASK_TEST,
34
+ } from './lib/constants.mjs'
35
+ import { badConfig, messageOf, nerModeUnsupported, nerUnsupported, scopeUnsupported } from './lib/errors.mjs'
36
+ import { createStripper } from './lib/strip.mjs'
37
+ import { maskMessages } from './lib/mask.mjs'
38
+ import { makeEventGate, maybeAppendSessionEvent } from './lib/gate.mjs'
39
+ import { RestoreStore } from './lib/store.mjs'
40
+ import { dshMaskDomainSpec } from './lib/domain.mjs'
41
+
42
+ export const name = PLUGIN_NAME
43
+
44
+ /** 必需服务:缺失即加载失败(响亮)。 */
45
+ export const inject = ['commands', 'storageDomain']
46
+
47
+ /**
48
+ * 宿主 append 是否盖章 ignorable 信封(运行时能力探测)。
49
+ * 在全新 detached Context 上构造 SessionStore(绝不接入宿主持久化):追加一条带
50
+ * { ignorable: true } 的探测事件并回读信封标记。rc.6 的 append 静默丢弃未知选项
51
+ * 键 → 标记缺失 → false(门保持关闭);支持 ignorable 信封的宿主 → true。
52
+ * @returns {boolean} 宿主支持 ignorable 信封。
53
+ */
54
+ export function probeIgnorableAppend() {
55
+ try {
56
+ const store = new SessionStore(new Context())
57
+ const session = store.create()
58
+ const event = session.append(SESSION_EVENTS.APPLIED, {
59
+ sessionId: 'probe',
60
+ replaced: 0,
61
+ distribution: {},
62
+ // @ts-ignore rc.6 append has no envelope option — probing it is the point.
63
+ }, /** @type {any} */ ({ ignorable: true }))
64
+ return event?.ignorable === true
65
+ } catch {
66
+ return false
67
+ }
68
+ }
69
+
70
+ /**
71
+ * 插件配置(Schemastery,全部可 cordis.yml 覆盖;无硬编码 tunable)。
72
+ * @typedef {import('./types.d.ts').Config} Config
73
+ */
74
+ export const Config = Schema.object({
75
+ enabled: Schema.boolean().default(DEFAULTS.ENABLED),
76
+ mode: Schema.union([MODES.REGEX, MODES.REGEX_NER]).default(DEFAULTS.MODE),
77
+ entities: Schema.array(Schema.string()).default(DEFAULTS.ENTITIES),
78
+ scope: Schema.union([SCOPES.MESSAGES, 'tools']).default(DEFAULTS.SCOPE),
79
+ registerCommand: Schema.boolean().default(DEFAULTS.REGISTER_COMMAND),
80
+ registerTools: Schema.boolean().default(DEFAULTS.REGISTER_TOOLS),
81
+ persistRestoreTable: Schema.boolean().default(DEFAULTS.PERSIST_RESTORE_TABLE),
82
+ maxRestoreEntriesPerSession: Schema.number().default(DEFAULTS.MAX_RESTORE_ENTRIES_PER_SESSION),
83
+ maxSessions: Schema.number().default(DEFAULTS.MAX_SESSIONS),
84
+ })
85
+
86
+ /**
87
+ * 显式补齐默认 + 加载期校验(非法配置响亮失败)。
88
+ * @param {Partial<Config>|undefined} config - cordis loader 传入的配置。
89
+ * @returns {Required<Config> & {entities: string[]}} 校验后的配置。
90
+ */
91
+ export function resolveConfig(config = {}) {
92
+ const resolved = {
93
+ enabled: config.enabled ?? DEFAULTS.ENABLED,
94
+ mode: config.mode ?? DEFAULTS.MODE,
95
+ entities: [...(config.entities ?? DEFAULTS.ENTITIES)],
96
+ scope: config.scope ?? DEFAULTS.SCOPE,
97
+ registerCommand: config.registerCommand ?? DEFAULTS.REGISTER_COMMAND,
98
+ registerTools: config.registerTools ?? DEFAULTS.REGISTER_TOOLS,
99
+ persistRestoreTable: config.persistRestoreTable ?? DEFAULTS.PERSIST_RESTORE_TABLE,
100
+ maxRestoreEntriesPerSession: config.maxRestoreEntriesPerSession ?? DEFAULTS.MAX_RESTORE_ENTRIES_PER_SESSION,
101
+ maxSessions: config.maxSessions ?? DEFAULTS.MAX_SESSIONS,
102
+ }
103
+ if (resolved.enabled === false) return resolved
104
+
105
+ if (resolved.mode === MODES.REGEX_NER) {
106
+ throw nerModeUnsupported(resolved.mode)
107
+ }
108
+ if (resolved.mode !== MODES.REGEX) {
109
+ throw badConfig(`mode ${JSON.stringify(resolved.mode)} must be one of regex|regex+ner`)
110
+ }
111
+ if (resolved.scope !== SCOPES.MESSAGES) {
112
+ throw scopeUnsupported(resolved.scope)
113
+ }
114
+ const seen = new Set()
115
+ resolved.entities = resolved.entities.filter((entity) => {
116
+ if (seen.has(entity)) return false
117
+ seen.add(entity)
118
+ return true
119
+ })
120
+ if (resolved.entities.length === 0) {
121
+ throw badConfig('entities must list at least one entity type')
122
+ }
123
+ if (resolved.entities.length > LIMITS.MAX_ENTITY_COUNT) {
124
+ throw badConfig(`entities must list at most ${LIMITS.MAX_ENTITY_COUNT} types`)
125
+ }
126
+ for (const entity of resolved.entities) {
127
+ if (!ENTITY_NAMES.includes(entity)) {
128
+ throw badConfig(`unknown entity ${JSON.stringify(entity)}; valid: ${ENTITY_NAMES.join(', ')}`)
129
+ }
130
+ if (NER_ENTITIES.includes(entity)) {
131
+ throw nerUnsupported(entity)
132
+ }
133
+ }
134
+ if (!Number.isInteger(resolved.maxRestoreEntriesPerSession)
135
+ || resolved.maxRestoreEntriesPerSession < LIMITS.MIN_RESTORE_ENTRIES
136
+ || resolved.maxRestoreEntriesPerSession > LIMITS.MAX_RESTORE_ENTRIES) {
137
+ throw badConfig(`maxRestoreEntriesPerSession must be an integer in [${LIMITS.MIN_RESTORE_ENTRIES}, ${LIMITS.MAX_RESTORE_ENTRIES}]`)
138
+ }
139
+ if (!Number.isInteger(resolved.maxSessions)
140
+ || resolved.maxSessions < LIMITS.MIN_SESSIONS
141
+ || resolved.maxSessions > LIMITS.MAX_SESSIONS) {
142
+ throw badConfig(`maxSessions must be an integer in [${LIMITS.MIN_SESSIONS}, ${LIMITS.MAX_SESSIONS}]`)
143
+ }
144
+ return resolved
145
+ }
146
+
147
+ /** /mask 命令帮助文案。 */
148
+ const HELP_TEXT = [
149
+ 'mask: usage — /mask [status | on | off | restore <text> | help]',
150
+ ' status show masking state: enabled, total replaced, type distribution (default)',
151
+ ' on enable masking at runtime (config.enabled is the persistent switch)',
152
+ ' off disable masking at runtime',
153
+ ' restore unmap placeholders in <text> back to the values stored for this session',
154
+ ].join('\n')
155
+
156
+ /**
157
+ * /mask status 渲染(纯函数)。
158
+ * @param {{enabled: boolean, replaced: number, distribution: Record<string, number>}} stats - 统计。
159
+ * @returns {string} 状态文本。
160
+ */
161
+ export function renderStatus(stats) {
162
+ const distribution = Object.entries(stats.distribution ?? {}).sort((a, b) => b[1] - a[1])
163
+ return [
164
+ `mask: ${stats.enabled ? 'enabled' : 'disabled (runtime off)'}`,
165
+ ` replaced: ${stats.replaced} total`,
166
+ ` distribution: ${distribution.length === 0 ? '(none yet)' : distribution.map(([label, count]) => `${label}=${count}`).join(', ')}`,
167
+ ].join('\n')
168
+ }
169
+
170
+ /**
171
+ * mask_test 结果渲染(纯函数)。
172
+ * @param {{ok: boolean, masked?: string, replaced?: number, distribution?: Array<{label?: string, count?: number}>, error?: string}} value - 工具结果。
173
+ * @returns {string} 渲染文本。
174
+ */
175
+ export function renderMaskTest(value) {
176
+ if (!value.ok) return `mask_test failed: ${value.error ?? 'unknown error'}`
177
+ const distribution = (value.distribution ?? []).map((entry) => `${entry.label}=${entry.count}`).join(', ')
178
+ return [
179
+ `mask_test: replaced ${value.replaced} PII value(s)${distribution ? ` (${distribution})` : ''}`,
180
+ 'masked:',
181
+ value.masked,
182
+ ].join('\n')
183
+ }
184
+
185
+ /**
186
+ * mask_test 工具定义:试跑一段文本看替换效果;绝不回显原文。
187
+ * @param {string[]} entities - 启用的实体类型。
188
+ * @param {number} maxEntries - 单次 stripper 条目上限。
189
+ * @returns {object} 工具定义。
190
+ */
191
+ export function makeMaskTestTool(entities, maxEntries) {
192
+ return defineTool({
193
+ name: TOOL_MASK_TEST,
194
+ description: 'Mask a snippet of text through the PII detector and report the placeholder result plus the replacement count and type distribution. It never returns the original values.',
195
+ parameters: {
196
+ text: { type: 'string', required: true, description: 'The text to run through the PII masker. Detected PII (phone, email, ID card, bank card, key, IP, as configured) is replaced with <TYPE_N> placeholders.' },
197
+ },
198
+ output: {
199
+ schema: {
200
+ type: 'object',
201
+ additionalProperties: false,
202
+ properties: {
203
+ ok: { type: 'boolean', required: true },
204
+ masked: { type: 'string' },
205
+ replaced: { type: 'integer' },
206
+ distribution: {
207
+ type: 'array',
208
+ items: {
209
+ type: 'object',
210
+ additionalProperties: false,
211
+ properties: {
212
+ label: { type: 'string' },
213
+ count: { type: 'integer' },
214
+ },
215
+ },
216
+ },
217
+ error: { type: 'string' },
218
+ },
219
+ },
220
+ render(_args, value) {
221
+ return [{ type: 'text', text: renderMaskTest(value) }]
222
+ },
223
+ },
224
+ async execute(args, exec) {
225
+ exec.signal?.throwIfAborted()
226
+ try {
227
+ const stripper = createStripper({ entities, maxEntries })
228
+ const masked = stripper.strip(args.text)
229
+ const stats = stripper.stats()
230
+ return {
231
+ ok: true,
232
+ masked,
233
+ replaced: stats.replaced,
234
+ distribution: Object.entries(stats.distribution).map(([label, count]) => ({ label, count })),
235
+ }
236
+ } catch (error) {
237
+ return { ok: false, masked: '', replaced: 0, distribution: [], error: messageOf(error) }
238
+ }
239
+ },
240
+ })
241
+ }
242
+
243
+ /**
244
+ * 插件挂载。enabled:false 时不注册任何东西;非法配置在加载期响亮抛错。
245
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Cordis 上下文。
246
+ * @param {Partial<Config>} [config] - 插件配置。
247
+ */
248
+ export function apply(ctx, config = {}) {
249
+ const resolved = resolveConfig(config)
250
+ if (resolved.enabled === false) return
251
+
252
+ const logger = ctx.logger(PLUGIN_NAME)
253
+ const warn = (message) => logger.warn(message)
254
+ const eventGate = makeEventGate(KNOWN_SESSION_EVENT_TYPES, probeIgnorableAppend())
255
+
256
+ // --- 恢复表:ctx.storageDomain 领域 'dsh_mask'(异步打开,操作路径 await)。
257
+ /** @type {Promise<any>} 打开的领域(含 table/close),RestoreStore 按需消费。 */
258
+ const domainPromise = ctx.storageDomain.open(dshMaskDomainSpec).then((domain) => {
259
+ ctx.effect(() => () => { void domain.close() }, `${PLUGIN_NAME}.domain.close`)
260
+ return domain
261
+ })
262
+ domainPromise.catch(() => {}) // 消费方各自处理拒绝;此处仅避免未处理拒绝告警。
263
+
264
+ const store = new RestoreStore({
265
+ entities: resolved.entities,
266
+ maxEntries: resolved.maxRestoreEntriesPerSession,
267
+ maxSessions: resolved.maxSessions,
268
+ persist: resolved.persistRestoreTable,
269
+ domainPromise: resolved.persistRestoreTable ? domainPromise : null,
270
+ onError: (error) => warn(messageOf(error)),
271
+ })
272
+
273
+ // --- 运行时开关(/mask on|off;重启回到 config.enabled)。
274
+ let runtimeEnabled = true
275
+
276
+ // --- agent/pre-step 遮罩(waterfall:先 next() 取下游决策,再遮罩其消息)。
277
+ ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
278
+ if (!runtimeEnabled) return next()
279
+ const decision = await next()
280
+ if (decision.kind !== 'enter') return decision
281
+ const sessionId = agent?.session?.id
282
+ if (sessionId === undefined || sessionId === null || sessionId === '') return decision
283
+ const stripper = store.stripperFor(sessionId)
284
+ const before = stripper.stats()
285
+ const { messages: maskedMessages, replaced } = maskMessages(decision.messages, stripper)
286
+ if (replaced === 0) return decision
287
+ const after = stripper.stats()
288
+ const distribution = {}
289
+ for (const [label, count] of Object.entries(after.distribution)) {
290
+ const delta = count - (before.distribution[label] ?? 0)
291
+ if (delta > 0) distribution[label] = delta
292
+ }
293
+ void store.persist(sessionId)
294
+ maybeAppendSessionEvent(agent.session, SESSION_EVENTS.APPLIED, {
295
+ sessionId,
296
+ replaced,
297
+ distribution,
298
+ }, eventGate, warn)
299
+ return { kind: 'enter', messages: maskedMessages }
300
+ })
301
+
302
+ // --- /mask 命令(Consumer)。
303
+ if (resolved.registerCommand) {
304
+ ctx.commands.register({
305
+ name: COMMAND_NAME,
306
+ description: 'PII masking status and controls: status (replaced count + type distribution), on/off (runtime toggle), restore <text> (unmap placeholders to this session\'s values), help.',
307
+ input: { hint: '[status | on | off | restore <text> | help]' },
308
+ async handler(invocation) {
309
+ return handleMaskCommand(invocation)
310
+ },
311
+ })
312
+ }
313
+
314
+ /**
315
+ * /mask 命令处理器。
316
+ * @param {import('@deepseek-ai/dsh-commands').CommandInvocation} invocation - 命令调用。
317
+ * @returns {Promise<import('@deepseek-ai/dsh-commands').CommandResult>} 命令结果。
318
+ */
319
+ async function handleMaskCommand(invocation) {
320
+ const { agent, rawInput } = invocation
321
+ const sessionId = agent?.session?.id
322
+ const input = (rawInput ?? '').trim()
323
+ const [action, ...rest] = input.split(/\s+/u)
324
+ const actionKey = (action || 'status').toLowerCase()
325
+ try {
326
+ if (actionKey === 'help' || !['status', 'on', 'off', 'restore'].includes(actionKey)) {
327
+ return { kind: 'success', text: HELP_TEXT }
328
+ }
329
+ if (actionKey === 'on') {
330
+ runtimeEnabled = true
331
+ return { kind: 'success', text: 'mask: masking enabled (runtime; config.enabled is the persistent switch)' }
332
+ }
333
+ if (actionKey === 'off') {
334
+ runtimeEnabled = false
335
+ return { kind: 'success', text: 'mask: masking disabled (runtime; re-enable with /mask on)' }
336
+ }
337
+ if (actionKey === 'status') {
338
+ const stats = sessionId ? store.stats(sessionId) : { replaced: 0, distribution: {} }
339
+ return { kind: 'success', text: renderStatus({ enabled: runtimeEnabled, ...stats }) }
340
+ }
341
+ if (actionKey === 'restore') {
342
+ const snippet = rest.join(' ')
343
+ if (sessionId === undefined || sessionId === null || sessionId === '') {
344
+ return { kind: 'error', text: 'mask: restore requires an active session' }
345
+ }
346
+ if (snippet === '') {
347
+ return { kind: 'error', text: 'mask: restore needs text, e.g. /mask restore <PHONE_1>' }
348
+ }
349
+ const restored = await store.restore(sessionId, snippet)
350
+ return { kind: 'success', text: `mask: restored\n${restored}` }
351
+ }
352
+ return { kind: 'success', text: HELP_TEXT }
353
+ } catch (error) {
354
+ const message = `mask: ${actionKey} failed: ${messageOf(error)}`
355
+ logger.error(message)
356
+ return { kind: 'error', text: message }
357
+ }
358
+ }
359
+
360
+ // --- mask_test 模型工具(Consumer;tools 服务存在时才注册,随 fiber 卸载撤销)。
361
+ if (resolved.registerTools) {
362
+ ctx.inject(['tools'], (toolCtx) => {
363
+ toolCtx.tools.register(makeMaskTestTool(resolved.entities, resolved.maxRestoreEntriesPerSession))
364
+ })
365
+ }
366
+ }
367
+
368
+ export {
369
+ // 复用/测试面:纯函数与词汇。
370
+ RestoreStore,
371
+ createStripper,
372
+ maskMessages,
373
+ makeEventGate,
374
+ maybeAppendSessionEvent,
375
+ dshMaskDomainSpec,
376
+ nerUnsupported,
377
+ nerModeUnsupported,
378
+ scopeUnsupported,
379
+ }
@@ -0,0 +1,90 @@
1
+ // lib/constants.mjs — 词汇表与协议常量(零依赖)。
2
+
3
+ // 插件标识、命令名与工具名。
4
+ export const PLUGIN_NAME = 'mask'
5
+ export const COMMAND_NAME = 'mask'
6
+ export const TOOL_MASK_TEST = 'mask_test'
7
+
8
+ // ctx.storageDomain 领域名(恢复表 + 统计表)。
9
+ // 下划线命名:storage-domain 的域名正则不允许连字符。
10
+ export const DOMAIN_NAME = 'dsh_mask'
11
+
12
+ // 存储领域表名。
13
+ export const RESTORE_TABLE = 'restore'
14
+
15
+ // 实体类型词汇(与 lib/strip.mjs 的检测器同源)。
16
+ // regex-capable:纯正则可检出;ner-only:需外部识别器(mode: regex+ner)。
17
+ export const ENTITY_NAMES = Object.freeze([
18
+ 'phone',
19
+ 'email',
20
+ 'id-card',
21
+ 'bank-card',
22
+ 'key',
23
+ 'ip',
24
+ 'person',
25
+ 'address',
26
+ ])
27
+
28
+ export const REGEX_ENTITIES = Object.freeze(['phone', 'email', 'id-card', 'bank-card', 'key', 'ip'])
29
+ export const NER_ENTITIES = Object.freeze(['person', 'address'])
30
+
31
+ // 实体类型 -> 占位符标签(与上游 Pii-Stripper 的 ENTITY_LABEL_MAP 对齐)。
32
+ export const ENTITY_LABELS = Object.freeze({
33
+ phone: 'PHONE',
34
+ email: 'EMAIL',
35
+ 'id-card': 'ID_CARD',
36
+ 'bank-card': 'BANK_CARD',
37
+ key: 'KEY',
38
+ ip: 'IP',
39
+ person: 'PERSON',
40
+ address: 'ADDRESS',
41
+ })
42
+
43
+ // 检测模式词汇:regex 是唯一实现;regex+ner 为外部识别器预留(加载期响亮失败)。
44
+ export const MODES = Object.freeze({
45
+ REGEX: 'regex',
46
+ REGEX_NER: 'regex+ner',
47
+ })
48
+
49
+ // 作用域词汇:messages 是唯一实现(agent/pre-step 消息遮罩);
50
+ // tools(工具入参遮罩)预留并响亮失败。
51
+ export const SCOPES = Object.freeze({
52
+ MESSAGES: 'messages',
53
+ })
54
+
55
+ // 会话事件类型(插件自有;运行时是否 append 取决于宿主是否收录该类型,
56
+ // 或宿主 append 是否支持 ignorable 信封,见 lib/gate.mjs 的自适应门)。
57
+ export const SESSION_EVENTS = Object.freeze({
58
+ APPLIED: 'mask/applied',
59
+ })
60
+
61
+ // 领域错误码(稳定、可路由)。
62
+ export const ERROR_CODES = Object.freeze({
63
+ BAD_CONFIG: 'BAD_CONFIG',
64
+ NER_UNSUPPORTED: 'NER_UNSUPPORTED',
65
+ SCOPE_UNSUPPORTED: 'SCOPE_UNSUPPORTED',
66
+ REGISTRY_UNAVAILABLE: 'REGISTRY_UNAVAILABLE',
67
+ })
68
+
69
+ // 默认值(Config schema 的默认与 DEFAULT_* 常量同源;cordis.yml 可整体覆盖)。
70
+ export const DEFAULTS = Object.freeze({
71
+ ENABLED: true,
72
+ MODE: MODES.REGEX,
73
+ ENTITIES: ['phone', 'email', 'id-card', 'bank-card', 'key'],
74
+ SCOPE: SCOPES.MESSAGES,
75
+ REGISTER_COMMAND: true,
76
+ REGISTER_TOOLS: true,
77
+ PERSIST_RESTORE_TABLE: true,
78
+ MAX_RESTORE_ENTRIES_PER_SESSION: 500,
79
+ MAX_SESSIONS: 1000,
80
+ })
81
+
82
+ // 配置字段合法性边界(加载期校验,越界响亮失败)。
83
+ export const LIMITS = Object.freeze({
84
+ MIN_RESTORE_ENTRIES: 1,
85
+ MAX_RESTORE_ENTRIES: 1_000_000,
86
+ MIN_SESSIONS: 1,
87
+ MAX_SESSIONS: 1_000_000,
88
+ MAX_ENTITY_COUNT: 64,
89
+ MAX_TEXT_LENGTH: 1_000_000,
90
+ })
package/lib/domain.mjs ADDED
@@ -0,0 +1,26 @@
1
+ // lib/domain.mjs — 'dsh_mask' 存储领域声明(唯一允许 zod/DSH 包的 lib 模块:
2
+ // 领域记录 schema 是持久边界校验器,zod 与 defineDomain 是 harness 自身词汇)。
3
+
4
+ import { z } from 'zod'
5
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
6
+ import { DOMAIN_NAME, RESTORE_TABLE } from './constants.mjs'
7
+
8
+ /**
9
+ * 恢复表记录(按 sessionId 键):占位符 -> 原文的映射 + 更新时间。
10
+ * 注意:entries 是 PII 原文,只落在受控 storageDomain,绝不进会话日志。
11
+ */
12
+ export const restoreRecordSchema = z.object({
13
+ sessionId: z.string().min(1).max(128),
14
+ entries: z.record(z.string(), z.string()),
15
+ updatedAt: z.number().int().nonnegative(),
16
+ })
17
+
18
+ /**
19
+ * 'dsh_mask' 领域 spec:一张 'restore' 表,键为会话 id。
20
+ * version 变更即废弃整介质(预发布立场,无迁移)。
21
+ */
22
+ export const dshMaskDomainSpec = defineDomain({
23
+ name: DOMAIN_NAME,
24
+ version: 1,
25
+ tables: { [RESTORE_TABLE]: domainTable(restoreRecordSchema) },
26
+ })
package/lib/errors.mjs ADDED
@@ -0,0 +1,87 @@
1
+ // lib/errors.mjs — 结构化领域错误(code + details,零依赖)。
2
+
3
+ import { ERROR_CODES } from './constants.mjs'
4
+
5
+ /**
6
+ * 插件领域错误基类:稳定 code 供 UI/日志路由,details 携带机器可读事实。
7
+ */
8
+ export class MaskError extends Error {
9
+ /**
10
+ * @param {string} code - ERROR_CODES 之一。
11
+ * @param {string} message - 面向用户的说明。
12
+ * @param {object} [details] - 可选结构化事实。
13
+ */
14
+ constructor(code, message, details = undefined) {
15
+ super(message)
16
+ this.name = 'MaskError'
17
+ this.code = code
18
+ this.details = details
19
+ }
20
+ }
21
+
22
+ /**
23
+ * 配置非法(加载期响亮失败用)。
24
+ * @param {string} message - 具体非法项说明。
25
+ * @returns {MaskError} code=BAD_CONFIG。
26
+ */
27
+ export function badConfig(message) {
28
+ return new MaskError(ERROR_CODES.BAD_CONFIG, `dsh-mask config: ${message}`)
29
+ }
30
+
31
+ /**
32
+ * 配置请求了 NER(姓名/地址)识别,但纯 host 零依赖形态未捆绑识别器。
33
+ * @param {string} entity - 触发失败的实体名。
34
+ * @returns {MaskError} code=NER_UNSUPPORTED。
35
+ */
36
+ export function nerUnsupported(entity) {
37
+ return new MaskError(
38
+ ERROR_CODES.NER_UNSUPPORTED,
39
+ `entity ${JSON.stringify(entity)} requires mode "regex+ner" (name/address recognition), which the pure-host form does not bundle`,
40
+ { entity },
41
+ )
42
+ }
43
+
44
+ /**
45
+ * 配置请求了 regex+ner 检测模式(姓名/地址识别),但纯 host 零依赖形态未捆绑识别器。
46
+ * @param {unknown} mode - 配置值。
47
+ * @returns {MaskError} code=NER_UNSUPPORTED。
48
+ */
49
+ export function nerModeUnsupported(mode) {
50
+ return new MaskError(
51
+ ERROR_CODES.NER_UNSUPPORTED,
52
+ `mode ${JSON.stringify(mode)} (name/address recognition) is not bundled in the pure-host zero-dependency form; use "regex"`,
53
+ { mode },
54
+ )
55
+ }
56
+
57
+ /**
58
+ * 配置请求了未实现的作用域。
59
+ * @param {unknown} scope - 配置值。
60
+ * @returns {MaskError} code=SCOPE_UNSUPPORTED。
61
+ */
62
+ export function scopeUnsupported(scope) {
63
+ return new MaskError(
64
+ ERROR_CODES.SCOPE_UNSUPPORTED,
65
+ `scope ${JSON.stringify(scope)} is not implemented (only "messages" is; tool-argument masking is reserved)`,
66
+ { scope },
67
+ )
68
+ }
69
+
70
+ /**
71
+ * 恢复/统计领域不可用(打开/读写失败)。
72
+ * @param {string} reason - 失败原因。
73
+ * @returns {MaskError} code=REGISTRY_UNAVAILABLE。
74
+ */
75
+ export function registryUnavailable(reason) {
76
+ return new MaskError(ERROR_CODES.REGISTRY_UNAVAILABLE, `dsh-mask storage domain unavailable: ${reason}`, { reason })
77
+ }
78
+
79
+ /**
80
+ * 任意值 → 稳定消息文本(日志/结果用,不信任其字符串转换)。
81
+ * @param {unknown} error - 任意抛出的值。
82
+ * @returns {string} 消息文本。
83
+ */
84
+ export function messageOf(error) {
85
+ if (error instanceof Error) return error.message
86
+ return String(error)
87
+ }
package/lib/gate.mjs ADDED
@@ -0,0 +1,41 @@
1
+ // lib/gate.mjs — 会话事件自适应门(零依赖)。
2
+
3
+ import { PLUGIN_NAME } from './constants.mjs'
4
+
5
+ /**
6
+ * 会话事件自适应门:决策函数返回是否 append 以及是否带 ignorable 信封。
7
+ * 宿主 KNOWN_SESSION_EVENT_TYPES 收录的类型直接 append;未收录但宿主 append
8
+ * 支持 ignorable 信封(运行时探测)时以 { ignorable: true } append;其余
9
+ * 拒绝(rc.6:未收录类型落盘会让会话下次加载被持久化层拒绝)。
10
+ * @param {ReadonlySet<string>} knownTypes - KNOWN_SESSION_EVENT_TYPES。
11
+ * @param {boolean} [ignorableAppend] - 宿主 append 是否盖章 ignorable 信封。
12
+ * @returns {(type: string) => {append: boolean, ignorable: boolean}} 决策函数。
13
+ */
14
+ export function makeEventGate(knownTypes, ignorableAppend = false) {
15
+ return (type) => {
16
+ if (knownTypes.has(type)) return { append: true, ignorable: false }
17
+ if (ignorableAppend) return { append: true, ignorable: true }
18
+ return { append: false, ignorable: false }
19
+ }
20
+ }
21
+
22
+ /**
23
+ * 自适应 append:门通过才写会话事件;append 本身失败只警告绝不破坏会话。
24
+ * @param {object|null|undefined} session - Session(缺失即跳过)。
25
+ * @param {string} type - 事件类型。
26
+ * @param {object} data - 载荷。
27
+ * @param {(type: string) => {append: boolean, ignorable: boolean}} gate - makeEventGate 产物。
28
+ * @param {(message: string) => void} warn - 日志警告。
29
+ * @returns {unknown} 已 append 事件或 undefined。
30
+ */
31
+ export function maybeAppendSessionEvent(session, type, data, gate, warn) {
32
+ if (session === null || session === undefined) return undefined
33
+ const decision = gate(type)
34
+ if (!decision.append) return undefined
35
+ try {
36
+ return session.append(type, data, decision.ignorable ? { ignorable: true } : undefined)
37
+ } catch (error) {
38
+ warn(`${PLUGIN_NAME} session event ${type} append failed: ${error instanceof Error ? error.message : String(error)}`)
39
+ return undefined
40
+ }
41
+ }