dsh-mask 0.1.3 → 0.2.0

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/ARCHITECTURE.md CHANGED
@@ -16,7 +16,7 @@
16
16
  | `index.mjs` | The single host face: `Config` schema + `resolveConfig`, `probeIgnorableAppend`, the `agent/pre-step` masking listener, the `/mask` command handler, the `mask_test` tool factory, `apply()` |
17
17
  | `lib/constants.mjs` | Vocabulary + protocol constants (entity names/labels, modes, scopes, error codes, defaults, bounds) — zero dependency |
18
18
  | `lib/errors.mjs` | Structured domain errors (`MaskError` with stable `code` + `details`) |
19
- | `lib/strip.mjs` | The regex PII detector and `Stripper` (strip/stripInto/restore/mapping/stats/loadMapping) — zero dependency |
19
+ | `lib/strip.mjs` | The regex PII detector (`regexDetect`), the pluggable-detector `Stripper` (strip/stripInto/restore/mapping/stats/loadMapping), and `createStripper` — zero dependency |
20
20
  | `lib/mask.mjs` | Message masking: rewrite `UserMessage` text blocks through a `Stripper` — zero dependency |
21
21
  | `lib/sanitize.mjs` | Pure display/log redaction (PII entities, secrets, URL credentials, mapping summarization) |
22
22
  | `lib/gate.mjs` | Session-event adaptive gate (append only when the host records the type or supports `ignorable`) |
@@ -48,6 +48,10 @@ Ported from Pii-Stripper-Middleware's `core.py`:
48
48
  - Overlap resolution sorts by start ascending, then score descending, and keeps only non-overlapping spans (first-come, higher score wins at the same position) — an 18-digit ID card beats a 16–19-digit bank-card match.
49
49
  - Same original value reuses the same placeholder; the counter is monotonic per session so placeholders never collide across turns.
50
50
 
51
+ ## Detector Provider seam
52
+
53
+ The detector is a pluggable Provider: `Stripper` and `createStripper` accept an optional `detector: (text) => PIIEntity[]`. When omitted, the built-in `regexDetect` (the ported regex set) runs unchanged, so the zero-dependency default path is untouched. An external recognizer (e.g. NER for `person`/`address`) plugs in by supplying that one function; overlap resolution, placeholder reuse, counting, and restore all stay shared.
54
+
51
55
  ## Session events (adaptive gate)
52
56
 
53
57
  `mask/applied` is declared through `SessionEventMap` declaration merging in `types.d.ts`. At runtime the plugin appends it only when either (a) the host's `KNOWN_SESSION_EVENT_TYPES` already includes the type, or (b) the host `Session.append` supports the `ignorable` envelope (`probeIgnorableAppend`). On `0.1.1-rc.2` neither is true (verified: rc.2 append reads only `surfaceOp`/`sourceEventSeqs` and never stamps `ignorable`), so the gate stays closed and appends are skipped — sessions keep loading. The audit payload is counts + type distribution only, never plaintext or the mapping.
@@ -65,6 +69,6 @@ The `dsh_mask` domain has one `restore` table keyed by session id. Its record ho
65
69
 
66
70
  ## Reserved seams
67
71
 
68
- - `mode: regex+ner` — external name/address recognition; fails loudly until a recognizer is wired in.
69
- - `scope: tools` — masking tool arguments (`tools/pre-execute`); reserved.
70
- - A browser half would consume the restore table to transparently un-mask assistant bubbles; this pure-host form ships the host-side seam only.
72
+ - `mode: regex+ner` — external name/address recognition; fails loudly until a recognizer is wired in (the detector Provider seam above is the plug point).
73
+ - `scope: tools` — implemented as `tools/post-execute` result-content masking. Tool-argument rewriting is deliberately NOT offered upstream: `tools/pre-execute`'s `PreToolDecision` has no input rewrite because logged/rendered arguments must match what ran, so `tools` scope masks the other model-visible tool surface (the result content) instead of the arguments.
74
+ - A browser half would consume the restore table to transparently un-mask assistant bubbles; the host-side restore surface (`/mask restore` + `RestoreStore.restore`) ships, and the browser slot is feature-flagged behind `maskClientEnabled` (default false) pending live slot-catalog verification.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0] - 2026-08-26
9
+
10
+ ### Added
11
+
12
+ - **`tools` masking scope.** `scope` now accepts `'messages'` and/or `'tools'` (string or array, default `['messages']`). The `tools` surface masks tool-result text blocks on `tools/post-execute` before they are logged and fed back to the model. Tool-argument rewriting stays out of scope because the upstream `tools/pre-execute` `PreToolDecision` deliberately offers no input rewrite (logged/rendered arguments must match what ran).
13
+ - **Detector Provider seam.** `Stripper`/`createStripper` accept an optional `detector: (text) => PIIEntity[]`; the built-in `regexDetect` remains the zero-dependency default, unchanged, so an external NER recognizer can plug in later without touching the masking pipeline.
14
+ - **`maskClientEnabled` feature flag** (default `false`) for the future browser-half reveal bubble; the host restore surface (`/mask restore` + `RestoreStore.restore`) already backs it.
15
+
16
+ ## [0.1.4] - 2026-08-23
17
+
18
+ ### Changed
19
+
20
+ - Add the `[Unreleased]` Keep-a-Changelog section and refresh the repo-local development notes (`AGENTS.md`) to match the current `0.1.3` release.
21
+
8
22
  ## [0.1.3] - 2026-08-22
9
23
 
10
24
  ### Changed
package/LICENSE CHANGED
@@ -1,5 +1,3 @@
1
- Copyright 2026 PerryLink
2
-
3
1
  Apache License
4
2
  Version 2.0, January 2004
5
3
  http://www.apache.org/licenses/
@@ -34,9 +32,10 @@ Copyright 2026 PerryLink
34
32
  not limited to compiled object code, generated documentation,
35
33
  and conversions to other media types.
36
34
 
37
- "Work" shall mean the work of authorship made available under
38
- the License, as indicated by a copyright notice that is included in
39
- or attached to the work (an example is provided in the Appendix below).
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
40
39
 
41
40
  "Derivative Works" shall mean any work, whether in Source or Object
42
41
  form, that is based on (or derived from) the Work and for which the
@@ -46,21 +45,23 @@ Copyright 2026 PerryLink
46
45
  separable from, or merely link (or bind by name) to the interfaces of,
47
46
  the Work and Derivative Works thereof.
48
47
 
49
- "Contribution" shall mean, as submitted to the Licensor for inclusion
50
- in the Work by the copyright owner or by an individual or Legal Entity
51
- authorized to submit on behalf of the copyright owner. For the purposes
52
- of this definition, "submitted" means any form of electronic, verbal,
53
- or written communication sent to the Licensor or its representatives,
54
- including but not limited to communication on electronic mailing lists,
55
- source code control systems, and issue tracking systems that are managed
56
- by, or on behalf of, the Licensor for the purpose of discussing and
57
- improving the Work, but excluding communication that is conspicuously
58
- marked or otherwise designated in writing by the copyright owner as
59
- "Not a Contribution."
60
-
61
- "Contributor" shall mean Licensor and any Legal Entity on behalf of
62
- whom a Contribution has been received by the Licensor and subsequently
63
- incorporated within the Work.
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
64
65
 
65
66
  2. Grant of Copyright License. Subject to the terms and conditions of
66
67
  this License, each Contributor hereby grants to You a perpetual,
@@ -78,47 +79,53 @@ Copyright 2026 PerryLink
78
79
  by such Contributor that are necessarily infringed by their
79
80
  Contribution(s) alone or by combination of their Contribution(s)
80
81
  with the Work to which such Contribution(s) was submitted. If You
81
- institute patent litigation against any entity (including a cross-claim
82
- or counterclaim in a lawsuit) alleging that the Work or a Contribution
83
- incorporated within the Work constitutes direct or contributory patent
84
- infringement, then any patent licenses granted to You under this License
85
- for that Work shall terminate as of the date such litigation is filed.
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
86
88
 
87
89
  4. Redistribution. You may reproduce and distribute copies of the
88
90
  Work or Derivative Works thereof in any medium, with or without
89
91
  modifications, and in Source or Object form, provided that You
90
92
  meet the following conditions:
91
93
 
92
- (a) You must give any other recipients of the Work or Derivative Works
93
- a copy of this License; and
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
94
96
 
95
97
  (b) You must cause any modified files to carry prominent notices
96
98
  stating that You changed the files; and
97
99
 
98
100
  (c) You must retain, in the Source form of any Derivative Works
99
101
  that You distribute, all copyright, patent, trademark, and
100
- attribution notices from the Source form of the Work, excluding
101
- those notices that do not pertain to any part of the Derivative
102
- Works; and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
103
105
 
104
106
  (d) If the Work includes a "NOTICE" text file as part of its
105
- distribution, You must include a readable copy of the attribution
106
- notices contained within such NOTICE file, in at least one of the
107
- following places: within a NOTICE text file distributed as part of
108
- the Derivative Works; within the Source form or documentation, if
109
- provided along with the Derivative Works; or, within a display
110
- generated by the Derivative Works, if and wherever such third-party
111
- notices normally appear. The contents of the NOTICE file are for
112
- informational purposes only and do not modify the License. You may
113
- add Your own attribution notices within Derivative Works that You
114
- distribute, alongside or as an addendum to the NOTICE text from the
115
- Work, provided that such additional attribution notices cannot be
116
- construed as modifying the License.
117
-
118
- You may add Your own license statement for Your modifications and
119
- may provide additional grant of rights to use, copy, modify, merge,
120
- publish, distribute, sublicense, and/or sell copies of the Work under
121
- terms of Your choice, or to simply not grant those additional rights.
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
122
129
 
123
130
  5. Submission of Contributions. Unless You explicitly state otherwise,
124
131
  any Contribution intentionally submitted for inclusion in the Work
@@ -138,9 +145,8 @@ Copyright 2026 PerryLink
138
145
  Contributor provides its Contributions) on an "AS IS" BASIS,
139
146
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
140
147
  implied, including, without limitation, any warranties or conditions
141
- of TITLE, MERCHANTABILITY, SATISFACTORY QUALITY, NON-INFRINGEMENT,
142
- FITNESS FOR A PARTICULAR PURPOSE, or any other warranties or
143
- conditions. You are solely responsible for determining the
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
144
150
  appropriateness of using or redistributing the Work and assume any
145
151
  risks associated with Your exercise of permissions under this License.
146
152
 
@@ -149,23 +155,23 @@ Copyright 2026 PerryLink
149
155
  unless required by applicable law (such as deliberate and grossly
150
156
  negligent acts) or agreed to in writing, shall any Contributor be
151
157
  liable to You for damages, including any direct, indirect, special,
152
- incidental, or exemplary damages of any character arising as a result
153
- of this License or out of the use or inability to use the Work
154
- (including but not limited to damages for loss of goodwill, work
155
- stoppage, computer failure or malfunction, or all other commercial
156
- damages or losses), even if such Contributor has been advised of the
157
- possibility of such damages.
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
158
164
 
159
165
  9. Accepting Warranty or Additional Liability. While redistributing
160
- the Work or Derivative Works thereof, You may choose to offer, and
161
- charge a fee for, acceptance of support, warranty, indemnity, or
162
- other liability obligations and/or rights consistent with this License.
163
- However, in accepting such obligations, You may offer such obligations
164
- only on Your own behalf and on Your sole responsibility, not on behalf
165
- of any other Contributor, and only if You agree to indemnify, defend,
166
- and hold each Contributor harmless for any liability incurred by, or
167
- claims asserted against, such Contributor by reason of your accepting
168
- any such warranty or additional liability.
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
169
175
 
170
176
  END OF TERMS AND CONDITIONS
171
177
 
@@ -180,7 +186,7 @@ Copyright 2026 PerryLink
180
186
  same "printed page" as the copyright notice for easier
181
187
  identification within third-party archives.
182
188
 
183
- Copyright 2026 PerryLink
189
+ Copyright [yyyy] [name of copyright owner]
184
190
 
185
191
  Licensed under the Apache License, Version 2.0 (the "License");
186
192
  you may not use this file except in compliance with the License.
package/README.es.md CHANGED
@@ -83,7 +83,7 @@ Todas las opciones son campos Schemastery `Config` (modificables desde cordis.ym
83
83
  | `enabled` | `true` | Interruptor maestro |
84
84
  | `mode` | `regex` | Solo `regex` implementado (`regex+ner` reservado) |
85
85
  | `entities` | `[phone, email, id-card, bank-card, key]` | Tipos de PII a enmascarar; `ip` opt-in, `person`/`address` requieren NER |
86
- | `scope` | `messages` | Solo `messages` implementado |
86
+ | `scope` | `[messages]` | Superficies: `messages` (mensajes agent/pre-step) y `tools` (texto de resultados de herramientas). Acepta una cadena o un array, p. ej. `[messages, tools]` |
87
87
  | `registerCommand` | `true` | Registra el comando `/mask` |
88
88
  | `registerTools` | `true` | Registra la herramienta `mask_test` |
89
89
  | `persistRestoreTable` | `true` | Persiste la tabla en el dominio `dsh_mask` |
@@ -134,6 +134,10 @@ pnpm pack
134
134
 
135
135
  Sin paso de build: ESM puro, `index.mjs` y `lib/` son los artefactos enviados.
136
136
 
137
+ ### Benchmark
138
+
139
+ El benchmark de PII (P/R/F1 por tipo sobre 108 muestras sintéticas) está en [`benchmark/RESULTS.md`](benchmark/RESULTS.md); regenéralo con `node benchmark/run.mjs` (sin build, cero dependencias nuevas).
140
+
137
141
  ## Topics
138
142
 
139
143
  `dsh`, `dsh-plugin`, `deepseek-harness`, `deepseek`, `cordis`, `pii`, `mask`, `privacy`, `anonymization`, `security`
package/README.hi.md CHANGED
@@ -83,7 +83,7 @@ dsh --profile web --dump-config | grep -A2 'id: mask'
83
83
  | `enabled` | `true` | मुख्य स्विच |
84
84
  | `mode` | `regex` | केवल `regex` लागू (`regex+ner` आरक्षित) |
85
85
  | `entities` | `[phone, email, id-card, bank-card, key]` | PII प्रकार; `ip` opt-in, `person`/`address` को NER चाहिए |
86
- | `scope` | `messages` | केवल `messages` लागू |
86
+ | `scope` | `[messages]` | सतहें: `messages` (agent/pre-step संदेश) और `tools` (टूल परिणाम पाठ)। स्ट्रिंग या array स्वीकार करता है, जैसे `[messages, tools]` |
87
87
  | `registerCommand` | `true` | `/mask` कमांड पंजीकृत करें |
88
88
  | `registerTools` | `true` | `mask_test` टूल पंजीकृत करें |
89
89
  | `persistRestoreTable` | `true` | तालिका को `dsh_mask` डोमेन में सहेजें |
@@ -134,6 +134,10 @@ pnpm pack
134
134
 
135
135
  कोई build चरण नहीं: शुद्ध ESM, `index.mjs` और `lib/` ही भेजे गए आर्टिफ़ैक्ट हैं।
136
136
 
137
+ ### Benchmark
138
+
139
+ PII बेंचमार्क (108 सिंथेटिक नमूनों पर प्रति-प्रकार P/R/F1) [`benchmark/RESULTS.md`](benchmark/RESULTS.md) में है; `node benchmark/run.mjs` से दोबारा बनाएँ (कोई build नहीं, कोई नई निर्भरता नहीं)।
140
+
137
141
  ## Topics
138
142
 
139
143
  `dsh`, `dsh-plugin`, `deepseek-harness`, `deepseek`, `cordis`, `pii`, `mask`, `privacy`, `anonymization`, `security`
package/README.md CHANGED
@@ -90,12 +90,13 @@ All tunables are Schemastery `Config` fields (changeable from cordis.yml). An id
90
90
  | `enabled` | `true` | Master switch; `false` unregisters the listener, the `/mask` command, and the `mask_test` tool |
91
91
  | `mode` | `regex` | Detection mode; only `regex` is implemented (`regex+ner` for name/address recognition is reserved and fails loud) |
92
92
  | `entities` | `[phone, email, id-card, bank-card, key]` | Which PII types to mask; `ip` is also regex-capable (opt-in), `person`/`address` require NER |
93
- | `scope` | `messages` | Masking surface; only `messages` (agent messages) is implemented (`tools` argument masking is reserved) |
93
+ | `scope` | `[messages]` | Masking surface(s); `messages` masks agent/pre-step messages, `tools` masks tool-result text on tools/post-execute. Accepts a string or an array, e.g. `[messages, tools]` |
94
94
  | `registerCommand` | `true` | Register the `/mask` command |
95
95
  | `registerTools` | `true` | Register the `mask_test` tool when the tools service is present |
96
96
  | `persistRestoreTable` | `true` | Persist the restore table to the controlled `dsh_mask` storage domain (`false` = memory only) |
97
97
  | `maxRestoreEntriesPerSession` | `500` | Per-session restore entry cap (oldest evicted first) |
98
98
  | `maxSessions` | `1000` | In-memory session cap (least-recently-used evicted, mapping reloaded on demand) |
99
+ | `maskClientEnabled` | `false` | Feature flag for the browser half "reveal" bubble (defensive; off by default until the live slot catalog verifies the target slot) |
99
100
 
100
101
  Example override in your profile patch:
101
102
 
@@ -114,6 +115,7 @@ Example override in your profile patch:
114
115
  | Surface | Reveals plaintext | Notes |
115
116
  |---|---|---|
116
117
  | `agent/pre-step` masking | never | Rewrites messages to placeholders before they are logged or sent to the model |
118
+ | `tools/post-execute` masking | never | Rewrites tool-result text blocks to placeholders before they are logged or fed back to the model (scope: `tools`) |
117
119
  | `/mask status` | never | Enabled state, total replaced, type distribution |
118
120
  | `/mask on` / `/mask off` | never | Runtime toggle (resets to `config.enabled` on restart) |
119
121
  | `/mask restore <text>` | yes (explicit) | Unmaps placeholders back to the values stored for this session |
@@ -130,7 +132,7 @@ Example override in your profile patch:
130
132
  - **Plaintext never enters the session log.** The masked (placeholder) form is what gets logged and sent to the model, so model-visible content is reconstructable from the log in placeholder form; the originals stay in the restore table.
131
133
  - **Sanitize before display/log.** `lib/sanitize.mjs` redacts PII, secrets, and URL credentials before any text reaches the model or the log; `mask_test` and `/mask status` never echo originals.
132
134
  - **Controlled restore.** `/mask restore` is the single explicit reveal surface, and it only reads the mapping for the active session.
133
- - **Fail closed.** Unimplemented `mode` (`regex+ner`), `scope` (`tools`), NER-only entities, and out-of-bounds numbers all fail loudly at load.
135
+ - **Fail closed.** Unimplemented `mode` (`regex+ner`), unknown `scope` values, NER-only entities, and out-of-bounds numbers all fail loudly at load.
134
136
  - **Registrations are effects.** The listener, command, tool, and storage-domain close are all Cordis effects — stop/hot-reload removes them.
135
137
 
136
138
  ## Known limitations
@@ -153,6 +155,10 @@ pnpm pack # the published tarball
153
155
 
154
156
  There is no build step: pure ESM, `index.mjs` and `lib/` are the shipped artifacts.
155
157
 
158
+ ### Benchmark
159
+
160
+ The PII benchmark (per-type P/R/F1 over 108 synthetic samples) is published in [`benchmark/RESULTS.md`](benchmark/RESULTS.md); regenerate it with `node benchmark/run.mjs` (no build step, zero new dependencies).
161
+
156
162
  ## Topics
157
163
 
158
164
  `dsh`, `dsh-plugin`, `deepseek-harness`, `deepseek`, `cordis`, `pii`, `mask`, `privacy`, `anonymization`, `security`
package/README.pt.md CHANGED
@@ -83,7 +83,7 @@ Todas as opções são campos Schemastery `Config` (alteráveis via cordis.yml).
83
83
  | `enabled` | `true` | Interruptor mestre |
84
84
  | `mode` | `regex` | Só `regex` implementado (`regex+ner` reservado) |
85
85
  | `entities` | `[phone, email, id-card, bank-card, key]` | Tipos de PII; `ip` opt-in, `person`/`address` exigem NER |
86
- | `scope` | `messages` | Só `messages` implementado |
86
+ | `scope` | `[messages]` | Superfícies: `messages` (mensagens agent/pre-step) e `tools` (texto de resultados de ferramentas). Aceita string ou array, ex. `[messages, tools]` |
87
87
  | `registerCommand` | `true` | Registra o comando `/mask` |
88
88
  | `registerTools` | `true` | Registra a ferramenta `mask_test` |
89
89
  | `persistRestoreTable` | `true` | Persiste a tabela no domínio `dsh_mask` |
@@ -134,6 +134,10 @@ pnpm pack
134
134
 
135
135
  Sem etapa de build: ESM puro, `index.mjs` e `lib/` são os artefatos enviados.
136
136
 
137
+ ### Benchmark
138
+
139
+ O benchmark de PII (P/R/F1 por tipo em 108 amostras sintéticas) está em [`benchmark/RESULTS.md`](benchmark/RESULTS.md); regenere-o com `node benchmark/run.mjs` (sem build, zero dependências novas).
140
+
137
141
  ## Topics
138
142
 
139
143
  `dsh`, `dsh-plugin`, `deepseek-harness`, `deepseek`, `cordis`, `pii`, `mask`, `privacy`, `anonymization`, `security`
package/README.zh.md CHANGED
@@ -89,7 +89,7 @@ dsh --profile web --dump-config | grep -A2 'id: mask'
89
89
  | `enabled` | `true` | 总开关;`false` 卸载监听器、`/mask` 命令与 `mask_test` 工具 |
90
90
  | `mode` | `regex` | 检测模式;只有 `regex` 实现(`regex+ner` 姓名/地址识别预留并响亮失败) |
91
91
  | `entities` | `[phone, email, id-card, bank-card, key]` | 要遮罩的 PII 类型;`ip` 也支持正则(可选),`person`/`address` 需要 NER |
92
- | `scope` | `messages` | 遮罩作用域;只有 `messages`(agent 消息)实现(`tools` 入参遮罩预留) |
92
+ | `scope` | `[messages]` | 遮罩作用域;`messages` 遮罩 agent/pre-step 消息,`tools` 遮罩 tools/post-execute 工具结果。可为字符串或数组,如 `[messages, tools]` |
93
93
  | `registerCommand` | `true` | 注册 `/mask` 命令 |
94
94
  | `registerTools` | `true` | tools 服务存在时注册 `mask_test` 工具 |
95
95
  | `persistRestoreTable` | `true` | 把恢复表持久化到受控 `dsh_mask` 领域(`false` = 仅内存) |
@@ -152,6 +152,10 @@ pnpm pack # 发布 tarball
152
152
 
153
153
  无构建步骤:纯 ESM,`index.mjs` 与 `lib/` 即发布产物。
154
154
 
155
+ ### Benchmark
156
+
157
+ PII 基准(108 个合成样本的逐类 P/R/F1)见 [`benchmark/RESULTS.md`](benchmark/RESULTS.md);用 `node benchmark/run.mjs` 复现(无需构建、零新依赖)。
158
+
155
159
  ## Topics
156
160
 
157
161
  `dsh`, `dsh-plugin`, `deepseek-harness`, `deepseek`, `cordis`, `pii`, `mask`, `privacy`, `anonymization`, `security`
package/cordis.patch.yml CHANGED
@@ -36,10 +36,13 @@
36
36
  - id-card
37
37
  - bank-card
38
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
39
+ # Masking surface(s). 'messages' masks agent/pre-step messages before
40
+ # they enter the model; 'tools' masks tool-result text on
41
+ # tools/post-execute before it is logged and fed back to the model
42
+ # (tool-argument rewriting is blocked upstream — see ARCHITECTURE.md).
43
+ # Accepts a single string or an array: [messages, tools].
44
+ scope:
45
+ - messages
43
46
  # Surface switches: register the /mask command and the mask_test tool.
44
47
  registerCommand: true
45
48
  registerTools: true
@@ -52,3 +55,8 @@
52
55
  # Cap on in-memory session strippers; least-recently-used sessions are
53
56
  # evicted (mapping reloads from the storage domain on demand).
54
57
  maxSessions: 1000
58
+ # Browser half "reveal" bubble (defensive, feature-flagged). The host
59
+ # restore surface (/mask restore + the in-memory/domain restore table)
60
+ # already backs it; the client slot ships separately and is off by
61
+ # default until the live slot catalog verifies the target slot.
62
+ maskClientEnabled: false
package/index.mjs CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  } from './lib/constants.mjs'
35
35
  import { badConfig, messageOf, nerModeUnsupported, nerUnsupported, scopeUnsupported } from './lib/errors.mjs'
36
36
  import { createStripper } from './lib/strip.mjs'
37
- import { maskMessages } from './lib/mask.mjs'
37
+ import { maskMessages, maskContentBlocks } from './lib/mask.mjs'
38
38
  import { makeEventGate, maybeAppendSessionEvent } from './lib/gate.mjs'
39
39
  import { RestoreStore } from './lib/store.mjs'
40
40
  import { dshMaskDomainSpec } from './lib/domain.mjs'
@@ -78,12 +78,16 @@ export const Config = Schema.object({
78
78
  enabled: Schema.boolean().default(DEFAULTS.ENABLED),
79
79
  mode: Schema.union([MODES.REGEX, MODES.REGEX_NER]).default(DEFAULTS.MODE),
80
80
  entities: Schema.array(Schema.string()).default(DEFAULTS.ENTITIES),
81
- scope: Schema.union([SCOPES.MESSAGES, 'tools']).default(DEFAULTS.SCOPE),
81
+ scope: Schema.union([
82
+ Schema.array(Schema.union([SCOPES.MESSAGES, SCOPES.TOOLS])),
83
+ Schema.union([SCOPES.MESSAGES, SCOPES.TOOLS]),
84
+ ]).default(DEFAULTS.SCOPE),
82
85
  registerCommand: Schema.boolean().default(DEFAULTS.REGISTER_COMMAND),
83
86
  registerTools: Schema.boolean().default(DEFAULTS.REGISTER_TOOLS),
84
87
  persistRestoreTable: Schema.boolean().default(DEFAULTS.PERSIST_RESTORE_TABLE),
85
88
  maxRestoreEntriesPerSession: Schema.number().default(DEFAULTS.MAX_RESTORE_ENTRIES_PER_SESSION),
86
89
  maxSessions: Schema.number().default(DEFAULTS.MAX_SESSIONS),
90
+ maskClientEnabled: Schema.boolean().default(DEFAULTS.MASK_CLIENT_ENABLED),
87
91
  })
88
92
 
89
93
  /**
@@ -92,16 +96,18 @@ export const Config = Schema.object({
92
96
  * @returns {Required<Config> & {entities: string[]}} 校验后的配置。
93
97
  */
94
98
  export function resolveConfig(config = {}) {
99
+ const rawScope = config.scope ?? DEFAULTS.SCOPE
95
100
  const resolved = {
96
101
  enabled: config.enabled ?? DEFAULTS.ENABLED,
97
102
  mode: config.mode ?? DEFAULTS.MODE,
98
103
  entities: [...(config.entities ?? DEFAULTS.ENTITIES)],
99
- scope: config.scope ?? DEFAULTS.SCOPE,
104
+ scope: typeof rawScope === 'string' ? [rawScope] : [...rawScope],
100
105
  registerCommand: config.registerCommand ?? DEFAULTS.REGISTER_COMMAND,
101
106
  registerTools: config.registerTools ?? DEFAULTS.REGISTER_TOOLS,
102
107
  persistRestoreTable: config.persistRestoreTable ?? DEFAULTS.PERSIST_RESTORE_TABLE,
103
108
  maxRestoreEntriesPerSession: config.maxRestoreEntriesPerSession ?? DEFAULTS.MAX_RESTORE_ENTRIES_PER_SESSION,
104
109
  maxSessions: config.maxSessions ?? DEFAULTS.MAX_SESSIONS,
110
+ maskClientEnabled: config.maskClientEnabled ?? DEFAULTS.MASK_CLIENT_ENABLED,
105
111
  }
106
112
  if (resolved.enabled === false) return resolved
107
113
 
@@ -111,8 +117,19 @@ export function resolveConfig(config = {}) {
111
117
  if (resolved.mode !== MODES.REGEX) {
112
118
  throw badConfig(`mode ${JSON.stringify(resolved.mode)} must be one of regex|regex+ner`)
113
119
  }
114
- if (resolved.scope !== SCOPES.MESSAGES) {
115
- throw scopeUnsupported(resolved.scope)
120
+ const scopeSeen = new Set()
121
+ resolved.scope = resolved.scope.filter((surface) => {
122
+ if (scopeSeen.has(surface)) return false
123
+ scopeSeen.add(surface)
124
+ return true
125
+ })
126
+ if (resolved.scope.length === 0) {
127
+ throw badConfig('scope must list at least one surface (messages and/or tools)')
128
+ }
129
+ for (const surface of resolved.scope) {
130
+ if (surface !== SCOPES.MESSAGES && surface !== SCOPES.TOOLS) {
131
+ throw scopeUnsupported(surface)
132
+ }
116
133
  }
117
134
  const seen = new Set()
118
135
  resolved.entities = resolved.entities.filter((entity) => {
@@ -277,30 +294,62 @@ export function apply(ctx, config = {}) {
277
294
  let runtimeEnabled = true
278
295
 
279
296
  // --- agent/pre-step 遮罩(waterfall:先 next() 取下游决策,再遮罩其消息)。
280
- ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
281
- if (!runtimeEnabled) return next()
282
- const decision = await next()
283
- if (decision.kind !== 'enter') return decision
284
- const sessionId = agent?.session?.id
285
- if (sessionId === undefined || sessionId === null || sessionId === '') return decision
286
- const stripper = store.stripperFor(sessionId)
287
- const before = stripper.stats()
288
- const { messages: maskedMessages, replaced } = maskMessages(decision.messages, stripper)
289
- if (replaced === 0) return decision
290
- const after = stripper.stats()
291
- const distribution = {}
292
- for (const [label, count] of Object.entries(after.distribution)) {
293
- const delta = count - (before.distribution[label] ?? 0)
294
- if (delta > 0) distribution[label] = delta
295
- }
296
- void store.persist(sessionId)
297
- maybeAppendSessionEvent(agent.session, SESSION_EVENTS.APPLIED, {
298
- sessionId,
299
- replaced,
300
- distribution,
301
- }, eventGate, warn)
302
- return { kind: 'enter', messages: maskedMessages }
303
- })
297
+ if (resolved.scope.includes(SCOPES.MESSAGES)) {
298
+ ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
299
+ if (!runtimeEnabled) return next()
300
+ const decision = await next()
301
+ if (decision.kind !== 'enter') return decision
302
+ const sessionId = agent?.session?.id
303
+ if (sessionId === undefined || sessionId === null || sessionId === '') return decision
304
+ const stripper = store.stripperFor(sessionId)
305
+ const before = stripper.stats()
306
+ const { messages: maskedMessages, replaced } = maskMessages(decision.messages, stripper)
307
+ if (replaced === 0) return decision
308
+ const after = stripper.stats()
309
+ const distribution = {}
310
+ for (const [label, count] of Object.entries(after.distribution)) {
311
+ const delta = count - (before.distribution[label] ?? 0)
312
+ if (delta > 0) distribution[label] = delta
313
+ }
314
+ void store.persist(sessionId)
315
+ maybeAppendSessionEvent(agent.session, SESSION_EVENTS.APPLIED, {
316
+ sessionId,
317
+ replaced,
318
+ distribution,
319
+ }, eventGate, warn)
320
+ return { kind: 'enter', messages: maskedMessages }
321
+ })
322
+ }
323
+
324
+ // --- tools/post-execute 遮罩(scope: tools):工具结果 text 块里的 PII 在回喂模型
325
+ // 与落盘前脱敏为占位符。工具入参本身无法在 pre-execute 改写(上游契约),故 tools
326
+ // 作用域落在结果这一可改写的模型可见面(见 ARCHITECTURE.md)。
327
+ if (resolved.scope.includes(SCOPES.TOOLS)) {
328
+ ctx.on('tools/post-execute', async (exec, result, next) => {
329
+ if (!runtimeEnabled) return next()
330
+ const decision = await next()
331
+ if (decision.kind !== 'accept') return decision
332
+ const sessionId = exec?.agent?.session?.id
333
+ if (sessionId === undefined || sessionId === null || sessionId === '') return decision
334
+ const stripper = store.stripperFor(sessionId)
335
+ const before = stripper.stats()
336
+ const { blocks: maskedBlocks, replaced } = maskContentBlocks(result.content, stripper)
337
+ if (replaced === 0) return decision
338
+ const after = stripper.stats()
339
+ const distribution = {}
340
+ for (const [label, count] of Object.entries(after.distribution)) {
341
+ const delta = count - (before.distribution[label] ?? 0)
342
+ if (delta > 0) distribution[label] = delta
343
+ }
344
+ void store.persist(sessionId)
345
+ maybeAppendSessionEvent(exec.agent?.session, SESSION_EVENTS.APPLIED, {
346
+ sessionId,
347
+ replaced,
348
+ distribution,
349
+ }, eventGate, warn)
350
+ return { kind: 'accept', content: maskedBlocks }
351
+ })
352
+ }
304
353
 
305
354
  // --- /mask 命令(Consumer)。
306
355
  if (resolved.registerCommand) {
@@ -373,6 +422,7 @@ export {
373
422
  RestoreStore,
374
423
  createStripper,
375
424
  maskMessages,
425
+ maskContentBlocks,
376
426
  makeEventGate,
377
427
  maybeAppendSessionEvent,
378
428
  dshMaskDomainSpec,
package/lib/constants.mjs CHANGED
@@ -46,10 +46,12 @@ export const MODES = Object.freeze({
46
46
  REGEX_NER: 'regex+ner',
47
47
  })
48
48
 
49
- // 作用域词汇:messages 是唯一实现(agent/pre-step 消息遮罩);
50
- // tools(工具入参遮罩)预留并响亮失败。
49
+ // 作用域词汇:messages = agent/pre-step 入站消息遮罩;
50
+ // tools = tools/post-execute 工具结果遮罩(工具入参改写被上游
51
+ // tools/pre-execute 契约禁止,见 ARCHITECTURE.md)。
51
52
  export const SCOPES = Object.freeze({
52
53
  MESSAGES: 'messages',
54
+ TOOLS: 'tools',
53
55
  })
54
56
 
55
57
  // 会话事件类型(插件自有;运行时是否 append 取决于宿主是否收录该类型,
@@ -71,12 +73,13 @@ export const DEFAULTS = Object.freeze({
71
73
  ENABLED: true,
72
74
  MODE: MODES.REGEX,
73
75
  ENTITIES: ['phone', 'email', 'id-card', 'bank-card', 'key'],
74
- SCOPE: SCOPES.MESSAGES,
76
+ SCOPE: [SCOPES.MESSAGES],
75
77
  REGISTER_COMMAND: true,
76
78
  REGISTER_TOOLS: true,
77
79
  PERSIST_RESTORE_TABLE: true,
78
80
  MAX_RESTORE_ENTRIES_PER_SESSION: 500,
79
81
  MAX_SESSIONS: 1000,
82
+ MASK_CLIENT_ENABLED: false,
80
83
  })
81
84
 
82
85
  // 配置字段合法性边界(加载期校验,越界响亮失败)。
package/lib/errors.mjs CHANGED
@@ -55,14 +55,14 @@ export function nerModeUnsupported(mode) {
55
55
  }
56
56
 
57
57
  /**
58
- * 配置请求了未实现的作用域。
58
+ * 配置请求了未知作用域。
59
59
  * @param {unknown} scope - 配置值。
60
60
  * @returns {MaskError} code=SCOPE_UNSUPPORTED。
61
61
  */
62
62
  export function scopeUnsupported(scope) {
63
63
  return new MaskError(
64
64
  ERROR_CODES.SCOPE_UNSUPPORTED,
65
- `scope ${JSON.stringify(scope)} is not implemented (only "messages" is; tool-argument masking is reserved)`,
65
+ `scope ${JSON.stringify(scope)} is not a valid surface; expected "messages" and/or "tools"`,
66
66
  { scope },
67
67
  )
68
68
  }
package/lib/mask.mjs CHANGED
@@ -43,3 +43,28 @@ export function maskMessages(messages, stripper) {
43
43
  })
44
44
  return { messages: masked, replaced }
45
45
  }
46
+
47
+ /**
48
+ * 遮罩任意内容块数组(工具结果的 text 块等),非文本块原样保留。
49
+ * @param {object[]} blocks - 内容块数组(含 {type:'text', text})。
50
+ * @param {import('./strip.mjs').Stripper} stripper - 累计脱敏器。
51
+ * @returns {{blocks: object[], replaced: number}} 遮罩后数组与本次替换数。
52
+ */
53
+ export function maskContentBlocks(blocks, stripper) {
54
+ if (!Array.isArray(blocks)) return { blocks, replaced: 0 }
55
+ let replaced = 0
56
+ let changed = false
57
+ const masked = blocks.map((block) => {
58
+ if (block === null || typeof block !== 'object' || block.type !== 'text' || typeof block.text !== 'string') {
59
+ return block
60
+ }
61
+ const result = stripper.stripInto(block.text)
62
+ if (result.replaced > 0) {
63
+ replaced += result.replaced
64
+ changed = true
65
+ }
66
+ return { ...block, text: result.text }
67
+ })
68
+ if (!changed) return { blocks, replaced: 0 }
69
+ return { blocks: masked, replaced }
70
+ }
package/lib/strip.mjs CHANGED
@@ -31,19 +31,47 @@ export const BUILTIN_PATTERNS = Object.freeze([
31
31
  * @typedef {{text: string, entity: string, label: string, start: number, end: number, score: number}} PIIEntity
32
32
  */
33
33
 
34
+ /**
35
+ * 内置正则检测器(Provider 默认实现):在文本上跑全部 pattern,返回实体列表。
36
+ * 纯函数、零依赖;作为可插拔 detector 的缺省,不改变既有检测语义。
37
+ * @param {string} text - 待检测文本。
38
+ * @param {Array<{entity: string, source: RegExp, score: number}>} patterns - 正则 pattern 列表。
39
+ * @returns {PIIEntity[]} 实体列表。
40
+ */
41
+ export function regexDetect(text, patterns) {
42
+ /** @type {PIIEntity[]} */
43
+ const entities = []
44
+ for (const pattern of patterns) {
45
+ for (const match of text.matchAll(pattern.source)) {
46
+ entities.push({
47
+ text: match[0],
48
+ entity: pattern.entity,
49
+ label: ENTITY_LABELS[pattern.entity] ?? pattern.entity,
50
+ start: match.index,
51
+ end: match.index + match[0].length,
52
+ score: pattern.score,
53
+ })
54
+ }
55
+ }
56
+ return entities
57
+ }
58
+
34
59
  /**
35
60
  * PII 脱敏器:把 PII 替换为 `<LABEL_N>` 占位符,并能按映射还原。
36
61
  *
62
+ * 检测器是可插拔 Provider:`detector` 为 `(text) => PIIEntity[]` 时用它(如
63
+ * 外部 NER 识别器),否则回退到内置正则 {@link regexDetect}(零依赖默认,行为不变)。
37
64
  * 同一会话内跨请求累计(stripInto 不重置),保证同一原文始终复用同一占位符,
38
65
  * 使模型在整段历史里看到的占位符语义一致;strip() 是单次模式(每次重置),
39
66
  * 供 mask_test 这类"试跑一段文本"的独立场景使用。
40
67
  */
41
68
  export class Stripper {
42
69
  /**
43
- * @param {object} options - {patterns: Array<{entity: string, source: RegExp, score: number}>, maxEntries?: number}。
70
+ * @param {object} options - {patterns: Array<{entity: string, source: RegExp, score: number}>, detector?: (text: string) => PIIEntity[], maxEntries?: number}。
44
71
  */
45
72
  constructor(options) {
46
73
  this.patterns = options.patterns
74
+ this.detector = options.detector ?? null
47
75
  this.maxEntries = options.maxEntries ?? LIMITS.MAX_RESTORE_ENTRIES
48
76
  this.reset()
49
77
  }
@@ -151,26 +179,12 @@ export class Stripper {
151
179
  }
152
180
 
153
181
  /**
154
- * 正则检测全部实体。
182
+ * 检测全部实体:优先走注入的 detector Provider,否则回退内置正则。
155
183
  * @param {string} text - 文本。
156
184
  * @returns {PIIEntity[]} 实体列表。
157
185
  */
158
186
  _detect(text) {
159
- /** @type {PIIEntity[]} */
160
- const entities = []
161
- for (const pattern of this.patterns) {
162
- for (const match of text.matchAll(pattern.source)) {
163
- entities.push({
164
- text: match[0],
165
- entity: pattern.entity,
166
- label: ENTITY_LABELS[pattern.entity] ?? pattern.entity,
167
- start: match.index,
168
- end: match.index + match[0].length,
169
- score: pattern.score,
170
- })
171
- }
172
- }
173
- return entities
187
+ return this.detector !== null ? this.detector(text) : regexDetect(text, this.patterns)
174
188
  }
175
189
 
176
190
  /**
@@ -245,11 +259,12 @@ export class Stripper {
245
259
 
246
260
  /**
247
261
  * 按启用的实体集创建脱敏器。
248
- * @param {object} [options] - {entities?: string[], maxEntries?: number}。
262
+ * @param {object} [options] - {entities?: string[], maxEntries?: number, detector?: (text: string) => PIIEntity[]}。
263
+ * detector 为可插拔检测器 Provider;缺省时使用内置正则(零依赖默认)。
249
264
  * @returns {Stripper} 配置好的脱敏器。
250
265
  */
251
266
  export function createStripper(options = {}) {
252
267
  const enabled = new Set(options.entities ?? Object.keys(ENTITY_LABELS))
253
268
  const patterns = BUILTIN_PATTERNS.filter(pattern => enabled.has(pattern.entity))
254
- return new Stripper({ patterns, maxEntries: options.maxEntries })
269
+ return new Stripper({ patterns, maxEntries: options.maxEntries, detector: options.detector })
255
270
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-mask",
3
3
  "description": "PII masking middleware for DeepSeek Harness: anonymize names, phones, emails, ID cards, bank cards, keys, and addresses to placeholders before they reach the model, restore them at the display layer, keep the restore table only in memory and a controlled storage domain, never log plaintext, and expose /mask and the mask_test tool",
4
- "version": "0.1.3",
4
+ "version": "0.2.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/PerryLink/dsh-mask.git"
package/types.d.ts CHANGED
@@ -23,8 +23,11 @@ export interface Config {
23
23
  mode?: 'regex' | 'regex+ner'
24
24
  /** 启用的实体类型;regex 集为 phone/email/id-card/bank-card/key/ip,person/address 需 NER。 */
25
25
  entities?: string[]
26
- /** 遮罩作用域;只有 'messages'(agent/pre-step 消息)实现,'tools' 预留并响亮失败。 */
27
- scope?: 'messages' | 'tools'
26
+ /**
27
+ * 遮罩作用域;'messages' = agent/pre-step 入站消息,'tools' = tools/post-execute
28
+ * 工具结果。可传单个字符串或数组(如 `['messages', 'tools']`)。默认 `['messages']`。
29
+ */
30
+ scope?: 'messages' | 'tools' | Array<'messages' | 'tools'>
28
31
  /** 注册 /mask 命令(默认 true)。 */
29
32
  registerCommand?: boolean
30
33
  /** tools 服务存在时注册 mask_test 工具(默认 true)。 */
@@ -35,6 +38,8 @@ export interface Config {
35
38
  maxRestoreEntriesPerSession?: number
36
39
  /** 内存会话脱敏器上限(LRU 逐出,映射按需从领域回载)。 */
37
40
  maxSessions?: number
41
+ /** 浏览器半自动还原气泡开关(防御性 client slot;默认 false,未启用前对 UI 无副作用)。 */
42
+ maskClientEnabled?: boolean
38
43
  }
39
44
 
40
45
  /** mask_test 工具规范结果。 */