dsh-mask 0.1.4 → 0.2.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/ARCHITECTURE.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## Roles
8
8
 
9
- - **Consumes public services only**: `commands`, `storageDomain` (hard `inject`); `tools` (registered via `ctx.inject(['tools'], …)` when present). The masking seam is the `agent/pre-step` waterfall.
9
+ - **Consumes public services only**: `commands` (hard `inject`); `storageDomain` (optional `ctx.get` — missing means a memory-only restore table with a one-time warning); `tools` (registered via `ctx.inject(['tools'], …)` when present). The masking seam is the `agent/pre-step` waterfall.
10
10
  - **`lib/` is zero-DSH-dependency**: services are wired only at the boundary in `index.mjs`; `lib/` depends only on `node:` built-ins (the single sanctioned exception is `lib/domain.mjs`, which imports `zod` and `@deepseek-ai/dsh-storage-domain` because the domain record schema is a persistence-boundary validator).
11
11
 
12
12
  ## Module map
@@ -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,13 +48,17 @@ 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.
54
58
 
55
59
  ## Storage domain
56
60
 
57
- The `dsh_mask` domain has one `restore` table keyed by session id. Its record holds `entries: { placeholder: original }` plus `updatedAt`. This is the only place plaintext PII is stored, and only when `persistRestoreTable: true`; the `Stripper` keeps a bounded in-memory copy (`maxRestoreEntriesPerSession`, `maxSessions` with LRU eviction).
61
+ The `dsh_mask` domain has one `restore` table keyed by session id. Its record holds `entries: { placeholder: original }` plus `updatedAt`. This is the only place plaintext PII is stored, and only when `persistRestoreTable: true`; the `Stripper` keeps a bounded in-memory copy (`maxRestoreEntriesPerSession`, `maxSessions` with LRU eviction). `storageDomain` is an optional service: the bundle patch inserts only the `mask` row, so a profile that already composes the storage stack (`web`, via `@deepseek-ai/dsh-web-app`) provides it, while a bare profile without it gets a memory-only restore table (`persistRestoreTable` is a no-op with a one-time warning).
58
62
 
59
63
  ## Safety boundaries
60
64
 
@@ -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.1] - 2026-08-27
9
+
10
+ ### Fixed
11
+
12
+ - The bundle patch no longer inserts the storage stack (`@deepseek-ai/dsh-storage` / `dsh-storage-json` / `dsh-storage-domain`), which crashed `dsh web` with `duplicate loader entry id: storage` because `@deepseek-ai/dsh-web-app` already composes the same ids (issue #2). `storageDomain` is now an optional service: a profile that composes the storage stack provides it, while a bare profile without it degrades to a memory-only restore table with a one-time warning instead of hanging on `pending (waiting for service: storageDomain)`.
13
+
14
+ ## [0.2.0] - 2026-08-26
15
+
16
+ ### Added
17
+
18
+ - **`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).
19
+ - **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.
20
+ - **`maskClientEnabled` feature flag** (default `false`) for the future browser-half reveal bubble; the host restore surface (`/mask restore` + `RestoreStore.restore`) already backs it.
21
+
8
22
  ## [0.1.4] - 2026-08-23
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
@@ -74,6 +74,8 @@ dsh --profile web --dump-config | grep -A2 'id: mask'
74
74
  - **Canal tarball**: `pnpm pack` y luego `dsh plugin --profile web add ./dsh-mask-<version>.tgz`.
75
75
  - **Desinstalar**: `dsh plugin --profile web remove dsh-mask`.
76
76
 
77
+ `dsh-mask` ya no incluye la pila de almacenamiento. Los perfiles que ya la componen (el perfil `web` lo hace, vía `@deepseek-ai/dsh-web-app`) aportan `storageDomain`, así que la persistencia funciona de inmediato. En un perfil bare sin almacenamiento el plugin se monta y enmascara igualmente, pero la tabla es solo en memoria (se pierde al reiniciar): compón la pila de almacenamiento en tu parche de perfil, o pon `persistRestoreTable: false`.
78
+
77
79
  ## Configuration
78
80
 
79
81
  Todas las opciones son campos Schemastery `Config` (modificables desde cordis.yml). `cordis.patch.yml` documenta cada clave.
@@ -83,7 +85,7 @@ Todas las opciones son campos Schemastery `Config` (modificables desde cordis.ym
83
85
  | `enabled` | `true` | Interruptor maestro |
84
86
  | `mode` | `regex` | Solo `regex` implementado (`regex+ner` reservado) |
85
87
  | `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 |
88
+ | `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
89
  | `registerCommand` | `true` | Registra el comando `/mask` |
88
90
  | `registerTools` | `true` | Registra la herramienta `mask_test` |
89
91
  | `persistRestoreTable` | `true` | Persiste la tabla en el dominio `dsh_mask` |
@@ -134,6 +136,10 @@ pnpm pack
134
136
 
135
137
  Sin paso de build: ESM puro, `index.mjs` y `lib/` son los artefactos enviados.
136
138
 
139
+ ### Benchmark
140
+
141
+ 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).
142
+
137
143
  ## Topics
138
144
 
139
145
  `dsh`, `dsh-plugin`, `deepseek-harness`, `deepseek`, `cordis`, `pii`, `mask`, `privacy`, `anonymization`, `security`
package/README.hi.md CHANGED
@@ -74,6 +74,8 @@ dsh --profile web --dump-config | grep -A2 'id: mask'
74
74
  - **tarball चैनल**: `pnpm pack` फिर `dsh plugin --profile web add ./dsh-mask-<version>.tgz`।
75
75
  - **अनइंस्टॉल**: `dsh plugin --profile web remove dsh-mask`।
76
76
 
77
+ `dsh-mask` अब storage स्टैक बंडल नहीं करता। जो प्रोफ़ाइल इसे पहले से रचते हैं (`web` प्रोफ़ाइल `@deepseek-ai/dsh-web-app` के ज़रिए करती है) वे `storageDomain` देते हैं, इसलिए persistence तुरंत काम करता है। बिना storage वाले bare प्रोफ़ाइल में प्लगइन फिर भी माउंट होता है और मास्क करता है, पर restore तालिका केवल मेमोरी में रहती है (रीस्टार्ट पर खो जाती है): अपने प्रोफ़ाइल पैच में storage स्टैक रचें, या `persistRestoreTable: false` करें।
78
+
77
79
  ## Configuration
78
80
 
79
81
  सभी विकल्प Schemastery `Config` फ़ील्ड हैं (cordis.yml से बदले जा सकते हैं)। `cordis.patch.yml` हर कुंजी का दस्तावेज़ देता है।
@@ -83,7 +85,7 @@ dsh --profile web --dump-config | grep -A2 'id: mask'
83
85
  | `enabled` | `true` | मुख्य स्विच |
84
86
  | `mode` | `regex` | केवल `regex` लागू (`regex+ner` आरक्षित) |
85
87
  | `entities` | `[phone, email, id-card, bank-card, key]` | PII प्रकार; `ip` opt-in, `person`/`address` को NER चाहिए |
86
- | `scope` | `messages` | केवल `messages` लागू |
88
+ | `scope` | `[messages]` | सतहें: `messages` (agent/pre-step संदेश) और `tools` (टूल परिणाम पाठ)। स्ट्रिंग या array स्वीकार करता है, जैसे `[messages, tools]` |
87
89
  | `registerCommand` | `true` | `/mask` कमांड पंजीकृत करें |
88
90
  | `registerTools` | `true` | `mask_test` टूल पंजीकृत करें |
89
91
  | `persistRestoreTable` | `true` | तालिका को `dsh_mask` डोमेन में सहेजें |
@@ -134,6 +136,10 @@ pnpm pack
134
136
 
135
137
  कोई build चरण नहीं: शुद्ध ESM, `index.mjs` और `lib/` ही भेजे गए आर्टिफ़ैक्ट हैं।
136
138
 
139
+ ### Benchmark
140
+
141
+ PII बेंचमार्क (108 सिंथेटिक नमूनों पर प्रति-प्रकार P/R/F1) [`benchmark/RESULTS.md`](benchmark/RESULTS.md) में है; `node benchmark/run.mjs` से दोबारा बनाएँ (कोई build नहीं, कोई नई निर्भरता नहीं)।
142
+
137
143
  ## Topics
138
144
 
139
145
  `dsh`, `dsh-plugin`, `deepseek-harness`, `deepseek`, `cordis`, `pii`, `mask`, `privacy`, `anonymization`, `security`
package/README.md CHANGED
@@ -81,6 +81,8 @@ Then tailor the entity list in your profile patch:
81
81
  - **tarball channel**: `pnpm pack` in this repo, then `dsh plugin --profile web add ./dsh-mask-<version>.tgz`.
82
82
  - **uninstall**: `dsh plugin --profile web remove dsh-mask` (or remove the row from the profile patch).
83
83
 
84
+ `dsh-mask` no longer bundles the storage stack. Profiles that already compose it (the `web` profile does, via `@deepseek-ai/dsh-web-app`) provide `storageDomain`, so persistence works out of the box. On a bare profile without storage the plugin still mounts and masks, but the restore table is memory-only (lost on restart) — compose the storage stack in your profile patch, or set `persistRestoreTable: false`.
85
+
84
86
  ## Configuration
85
87
 
86
88
  All tunables are Schemastery `Config` fields (changeable from cordis.yml). An id-targeted override replaces the whole row — restate every key you need. `cordis.patch.yml` documents each key inline.
@@ -90,12 +92,13 @@ All tunables are Schemastery `Config` fields (changeable from cordis.yml). An id
90
92
  | `enabled` | `true` | Master switch; `false` unregisters the listener, the `/mask` command, and the `mask_test` tool |
91
93
  | `mode` | `regex` | Detection mode; only `regex` is implemented (`regex+ner` for name/address recognition is reserved and fails loud) |
92
94
  | `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) |
95
+ | `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
96
  | `registerCommand` | `true` | Register the `/mask` command |
95
97
  | `registerTools` | `true` | Register the `mask_test` tool when the tools service is present |
96
98
  | `persistRestoreTable` | `true` | Persist the restore table to the controlled `dsh_mask` storage domain (`false` = memory only) |
97
99
  | `maxRestoreEntriesPerSession` | `500` | Per-session restore entry cap (oldest evicted first) |
98
100
  | `maxSessions` | `1000` | In-memory session cap (least-recently-used evicted, mapping reloaded on demand) |
101
+ | `maskClientEnabled` | `false` | Feature flag for the browser half "reveal" bubble (defensive; off by default until the live slot catalog verifies the target slot) |
99
102
 
100
103
  Example override in your profile patch:
101
104
 
@@ -114,6 +117,7 @@ Example override in your profile patch:
114
117
  | Surface | Reveals plaintext | Notes |
115
118
  |---|---|---|
116
119
  | `agent/pre-step` masking | never | Rewrites messages to placeholders before they are logged or sent to the model |
120
+ | `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
121
  | `/mask status` | never | Enabled state, total replaced, type distribution |
118
122
  | `/mask on` / `/mask off` | never | Runtime toggle (resets to `config.enabled` on restart) |
119
123
  | `/mask restore <text>` | yes (explicit) | Unmaps placeholders back to the values stored for this session |
@@ -130,7 +134,7 @@ Example override in your profile patch:
130
134
  - **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
135
  - **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
136
  - **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.
137
+ - **Fail closed.** Unimplemented `mode` (`regex+ner`), unknown `scope` values, NER-only entities, and out-of-bounds numbers all fail loudly at load.
134
138
  - **Registrations are effects.** The listener, command, tool, and storage-domain close are all Cordis effects — stop/hot-reload removes them.
135
139
 
136
140
  ## Known limitations
@@ -153,6 +157,10 @@ pnpm pack # the published tarball
153
157
 
154
158
  There is no build step: pure ESM, `index.mjs` and `lib/` are the shipped artifacts.
155
159
 
160
+ ### Benchmark
161
+
162
+ 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).
163
+
156
164
  ## Topics
157
165
 
158
166
  `dsh`, `dsh-plugin`, `deepseek-harness`, `deepseek`, `cordis`, `pii`, `mask`, `privacy`, `anonymization`, `security`
package/README.pt.md CHANGED
@@ -74,6 +74,8 @@ dsh --profile web --dump-config | grep -A2 'id: mask'
74
74
  - **Canal tarball**: `pnpm pack` e depois `dsh plugin --profile web add ./dsh-mask-<version>.tgz`.
75
75
  - **Desinstalar**: `dsh plugin --profile web remove dsh-mask`.
76
76
 
77
+ `dsh-mask` não inclui mais a pilha de armazenamento. Perfis que já a compõem (o perfil `web` o faz, via `@deepseek-ai/dsh-web-app`) fornecem `storageDomain`, então a persistência funciona imediatamente. Em um perfil bare sem armazenamento o plugin monta e mascara mesmo assim, mas a tabela é só em memória (perdida ao reiniciar): componha a pilha de armazenamento no seu patch de perfil, ou defina `persistRestoreTable: false`.
78
+
77
79
  ## Configuration
78
80
 
79
81
  Todas as opções são campos Schemastery `Config` (alteráveis via cordis.yml). O `cordis.patch.yml` documenta cada chave.
@@ -83,7 +85,7 @@ Todas as opções são campos Schemastery `Config` (alteráveis via cordis.yml).
83
85
  | `enabled` | `true` | Interruptor mestre |
84
86
  | `mode` | `regex` | Só `regex` implementado (`regex+ner` reservado) |
85
87
  | `entities` | `[phone, email, id-card, bank-card, key]` | Tipos de PII; `ip` opt-in, `person`/`address` exigem NER |
86
- | `scope` | `messages` | Só `messages` implementado |
88
+ | `scope` | `[messages]` | Superfícies: `messages` (mensagens agent/pre-step) e `tools` (texto de resultados de ferramentas). Aceita string ou array, ex. `[messages, tools]` |
87
89
  | `registerCommand` | `true` | Registra o comando `/mask` |
88
90
  | `registerTools` | `true` | Registra a ferramenta `mask_test` |
89
91
  | `persistRestoreTable` | `true` | Persiste a tabela no domínio `dsh_mask` |
@@ -134,6 +136,10 @@ pnpm pack
134
136
 
135
137
  Sem etapa de build: ESM puro, `index.mjs` e `lib/` são os artefatos enviados.
136
138
 
139
+ ### Benchmark
140
+
141
+ 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).
142
+
137
143
  ## Topics
138
144
 
139
145
  `dsh`, `dsh-plugin`, `deepseek-harness`, `deepseek`, `cordis`, `pii`, `mask`, `privacy`, `anonymization`, `security`
package/README.zh.md CHANGED
@@ -80,6 +80,8 @@ dsh --profile web --dump-config | grep -A2 'id: mask'
80
80
  - **tarball 通道**:在本仓库 `pnpm pack`,再 `dsh plugin --profile web add ./dsh-mask-<version>.tgz`。
81
81
  - **卸载**:`dsh plugin --profile web remove dsh-mask`(或从 profile patch 删掉该行)。
82
82
 
83
+ `dsh-mask` 不再自带 storage 栈。已组合该栈的 profile(`web` profile 通过 `@deepseek-ai/dsh-web-app` 提供)自带 `storageDomain`,持久化开箱即用。未组合 storage 的 bare profile 仍可正常挂载与遮罩,但恢复表仅存内存(重启丢失)——请在 profile patch 中组合 storage 栈,或将 `persistRestoreTable` 设为 `false`。
84
+
83
85
  ## Configuration
84
86
 
85
87
  所有可调项都是 Schemastery `Config` 字段(可从 cordis.yml 覆盖)。按 id 覆盖会替换整行——请重述所有需要的键。`cordis.patch.yml` 逐键内联注释。
@@ -89,7 +91,7 @@ dsh --profile web --dump-config | grep -A2 'id: mask'
89
91
  | `enabled` | `true` | 总开关;`false` 卸载监听器、`/mask` 命令与 `mask_test` 工具 |
90
92
  | `mode` | `regex` | 检测模式;只有 `regex` 实现(`regex+ner` 姓名/地址识别预留并响亮失败) |
91
93
  | `entities` | `[phone, email, id-card, bank-card, key]` | 要遮罩的 PII 类型;`ip` 也支持正则(可选),`person`/`address` 需要 NER |
92
- | `scope` | `messages` | 遮罩作用域;只有 `messages`(agent 消息)实现(`tools` 入参遮罩预留) |
94
+ | `scope` | `[messages]` | 遮罩作用域;`messages` 遮罩 agent/pre-step 消息,`tools` 遮罩 tools/post-execute 工具结果。可为字符串或数组,如 `[messages, tools]` |
93
95
  | `registerCommand` | `true` | 注册 `/mask` 命令 |
94
96
  | `registerTools` | `true` | tools 服务存在时注册 `mask_test` 工具 |
95
97
  | `persistRestoreTable` | `true` | 把恢复表持久化到受控 `dsh_mask` 领域(`false` = 仅内存) |
@@ -152,6 +154,10 @@ pnpm pack # 发布 tarball
152
154
 
153
155
  无构建步骤:纯 ESM,`index.mjs` 与 `lib/` 即发布产物。
154
156
 
157
+ ### Benchmark
158
+
159
+ PII 基准(108 个合成样本的逐类 P/R/F1)见 [`benchmark/RESULTS.md`](benchmark/RESULTS.md);用 `node benchmark/run.mjs` 复现(无需构建、零新依赖)。
160
+
155
161
  ## Topics
156
162
 
157
163
  `dsh`, `dsh-plugin`, `deepseek-harness`, `deepseek`, `cordis`, `pii`, `mask`, `privacy`, `anonymization`, `security`
package/cordis.patch.yml CHANGED
@@ -2,20 +2,16 @@
2
2
  #
3
3
  # Every key below is a Config field (Schemastery schema); invalid values fail
4
4
  # the profile load loudly. See README.md "Configuration" for the full table.
5
+ #
6
+ # This patch inserts only the `mask` row. It deliberately does NOT insert the
7
+ # storage stack (`storage` / `storage-json` / `storage-domain`): a profile that
8
+ # already composes it — the `web` profile does, via @deepseek-ai/dsh-web-app —
9
+ # would collide on the duplicate loader entry id `storage` and refuse to boot
10
+ # (issue #2). The plugin treats `storageDomain` as an optional service: when
11
+ # the profile provides it, the restore table persists to the `dsh_mask` domain;
12
+ # on a profile without storage, the restore table is memory-only and
13
+ # `persistRestoreTable: true` is a no-op with a one-time warning.
5
14
  - 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
15
  - id: mask
20
16
  name: dsh-mask
21
17
  config:
@@ -36,15 +32,20 @@
36
32
  - id-card
37
33
  - bank-card
38
34
  - 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
35
+ # Masking surface(s). 'messages' masks agent/pre-step messages before
36
+ # they enter the model; 'tools' masks tool-result text on
37
+ # tools/post-execute before it is logged and fed back to the model
38
+ # (tool-argument rewriting is blocked upstream — see ARCHITECTURE.md).
39
+ # Accepts a single string or an array: [messages, tools].
40
+ scope:
41
+ - messages
43
42
  # Surface switches: register the /mask command and the mask_test tool.
44
43
  registerCommand: true
45
44
  registerTools: true
46
45
  # Persist the placeholder->original restore table to the controlled
47
- # dsh_mask storage domain (false = memory only, lost on restart).
46
+ # dsh_mask storage domain (false = memory only, lost on restart). A
47
+ # no-op with a one-time warning when the profile composes no storage
48
+ # stack (storageDomain service absent).
48
49
  persistRestoreTable: true
49
50
  # Per-session cap on restore entries; the oldest placeholders are
50
51
  # evicted first (their responses can no longer be restored).
@@ -52,3 +53,8 @@
52
53
  # Cap on in-memory session strippers; least-recently-used sessions are
53
54
  # evicted (mapping reloads from the storage domain on demand).
54
55
  maxSessions: 1000
56
+ # Browser half "reveal" bubble (defensive, feature-flagged). The host
57
+ # restore surface (/mask restore + the in-memory/domain restore table)
58
+ # already backs it; the client slot ships separately and is off by
59
+ # default until the live slot catalog verifies the target slot.
60
+ maskClientEnabled: false
package/index.mjs CHANGED
@@ -12,8 +12,9 @@
12
12
  // - 表面:/mask 命令(status|on|off|restore|help)与 mask_test 工具(试跑一段
13
13
  // 文本看替换效果,绝不回显原文)。
14
14
  //
15
- // 只消费公开服务:commands/storageDomain(inject 声明),tools 经 ctx.inject
16
- // 可选注册;lib/ 零 DSH 依赖,服务只在边界接线。
15
+ // 只消费公开服务:commands(inject 声明)、storageDomain(ctx.get 可选,缺失时
16
+ // 恢复表降级为纯内存并一次性告警),tools 经 ctx.inject 可选注册;lib/ 零 DSH
17
+ // 依赖,服务只在边界接线。
17
18
 
18
19
  import { Context } from '@deepseek-ai/cordis'
19
20
  import Schema from '@deepseek-ai/schemastery'
@@ -34,15 +35,19 @@ import {
34
35
  } from './lib/constants.mjs'
35
36
  import { badConfig, messageOf, nerModeUnsupported, nerUnsupported, scopeUnsupported } from './lib/errors.mjs'
36
37
  import { createStripper } from './lib/strip.mjs'
37
- import { maskMessages } from './lib/mask.mjs'
38
+ import { maskMessages, maskContentBlocks } from './lib/mask.mjs'
38
39
  import { makeEventGate, maybeAppendSessionEvent } from './lib/gate.mjs'
39
40
  import { RestoreStore } from './lib/store.mjs'
40
41
  import { dshMaskDomainSpec } from './lib/domain.mjs'
41
42
 
42
43
  export const name = PLUGIN_NAME
43
44
 
44
- /** 必需服务:缺失即加载失败(响亮)。 */
45
- export const inject = ['commands', 'storageDomain']
45
+ /**
46
+ * 必需服务:缺失即加载失败(响亮)。storageDomain 是可选服务(apply 内
47
+ * ctx.get 读取,缺失时恢复表降级为纯内存),不再作为 inject 硬依赖——否则
48
+ * bare profile 会卡在 `pending (waiting for service: storageDomain)`。
49
+ */
50
+ export const inject = ['commands']
46
51
 
47
52
  /**
48
53
  * 宿主 append 是否盖章 ignorable 信封(运行时能力探测)。
@@ -78,12 +83,16 @@ export const Config = Schema.object({
78
83
  enabled: Schema.boolean().default(DEFAULTS.ENABLED),
79
84
  mode: Schema.union([MODES.REGEX, MODES.REGEX_NER]).default(DEFAULTS.MODE),
80
85
  entities: Schema.array(Schema.string()).default(DEFAULTS.ENTITIES),
81
- scope: Schema.union([SCOPES.MESSAGES, 'tools']).default(DEFAULTS.SCOPE),
86
+ scope: Schema.union([
87
+ Schema.array(Schema.union([SCOPES.MESSAGES, SCOPES.TOOLS])),
88
+ Schema.union([SCOPES.MESSAGES, SCOPES.TOOLS]),
89
+ ]).default(DEFAULTS.SCOPE),
82
90
  registerCommand: Schema.boolean().default(DEFAULTS.REGISTER_COMMAND),
83
91
  registerTools: Schema.boolean().default(DEFAULTS.REGISTER_TOOLS),
84
92
  persistRestoreTable: Schema.boolean().default(DEFAULTS.PERSIST_RESTORE_TABLE),
85
93
  maxRestoreEntriesPerSession: Schema.number().default(DEFAULTS.MAX_RESTORE_ENTRIES_PER_SESSION),
86
94
  maxSessions: Schema.number().default(DEFAULTS.MAX_SESSIONS),
95
+ maskClientEnabled: Schema.boolean().default(DEFAULTS.MASK_CLIENT_ENABLED),
87
96
  })
88
97
 
89
98
  /**
@@ -92,16 +101,18 @@ export const Config = Schema.object({
92
101
  * @returns {Required<Config> & {entities: string[]}} 校验后的配置。
93
102
  */
94
103
  export function resolveConfig(config = {}) {
104
+ const rawScope = config.scope ?? DEFAULTS.SCOPE
95
105
  const resolved = {
96
106
  enabled: config.enabled ?? DEFAULTS.ENABLED,
97
107
  mode: config.mode ?? DEFAULTS.MODE,
98
108
  entities: [...(config.entities ?? DEFAULTS.ENTITIES)],
99
- scope: config.scope ?? DEFAULTS.SCOPE,
109
+ scope: typeof rawScope === 'string' ? [rawScope] : [...rawScope],
100
110
  registerCommand: config.registerCommand ?? DEFAULTS.REGISTER_COMMAND,
101
111
  registerTools: config.registerTools ?? DEFAULTS.REGISTER_TOOLS,
102
112
  persistRestoreTable: config.persistRestoreTable ?? DEFAULTS.PERSIST_RESTORE_TABLE,
103
113
  maxRestoreEntriesPerSession: config.maxRestoreEntriesPerSession ?? DEFAULTS.MAX_RESTORE_ENTRIES_PER_SESSION,
104
114
  maxSessions: config.maxSessions ?? DEFAULTS.MAX_SESSIONS,
115
+ maskClientEnabled: config.maskClientEnabled ?? DEFAULTS.MASK_CLIENT_ENABLED,
105
116
  }
106
117
  if (resolved.enabled === false) return resolved
107
118
 
@@ -111,8 +122,19 @@ export function resolveConfig(config = {}) {
111
122
  if (resolved.mode !== MODES.REGEX) {
112
123
  throw badConfig(`mode ${JSON.stringify(resolved.mode)} must be one of regex|regex+ner`)
113
124
  }
114
- if (resolved.scope !== SCOPES.MESSAGES) {
115
- throw scopeUnsupported(resolved.scope)
125
+ const scopeSeen = new Set()
126
+ resolved.scope = resolved.scope.filter((surface) => {
127
+ if (scopeSeen.has(surface)) return false
128
+ scopeSeen.add(surface)
129
+ return true
130
+ })
131
+ if (resolved.scope.length === 0) {
132
+ throw badConfig('scope must list at least one surface (messages and/or tools)')
133
+ }
134
+ for (const surface of resolved.scope) {
135
+ if (surface !== SCOPES.MESSAGES && surface !== SCOPES.TOOLS) {
136
+ throw scopeUnsupported(surface)
137
+ }
116
138
  }
117
139
  const seen = new Set()
118
140
  resolved.entities = resolved.entities.filter((entity) => {
@@ -256,13 +278,21 @@ export function apply(ctx, config = {}) {
256
278
  const warn = (message) => logger.warn(message)
257
279
  const eventGate = makeEventGate(KNOWN_SESSION_EVENT_TYPES, probeIgnorableAppend())
258
280
 
259
- // --- 恢复表:ctx.storageDomain 领域 'dsh_mask'(异步打开,操作路径 await)。
260
- /** @type {Promise<any>} 打开的领域(含 table/close),RestoreStore 按需消费。 */
261
- const domainPromise = ctx.storageDomain.open(dshMaskDomainSpec).then((domain) => {
262
- ctx.effect(() => () => { void domain.close() }, `${PLUGIN_NAME}.domain.close`)
263
- return domain
264
- })
265
- domainPromise.catch(() => {}) // 消费方各自处理拒绝;此处仅避免未处理拒绝告警。
281
+ // --- 恢复表:可选 storageDomain 的 'dsh_mask' 领域(异步打开,操作路径 await)。
282
+ // storageDomain 缺失(bare profile 未组合存储栈)时降级为纯内存恢复表,
283
+ // persistRestoreTable 视为 no-op 并一次性告警(可选 seam 失败关闭,绝不卡 pending)。
284
+ const storageDomain = ctx.get('storageDomain')
285
+ /** @type {Promise<any>|null} 打开的领域(含 table/close),RestoreStore 按需消费。 */
286
+ let domainPromise = null
287
+ if (storageDomain !== undefined) {
288
+ domainPromise = storageDomain.open(dshMaskDomainSpec).then((domain) => {
289
+ ctx.effect(() => () => { void domain.close() }, `${PLUGIN_NAME}.domain.close`)
290
+ return domain
291
+ })
292
+ domainPromise.catch(() => {}) // 消费方各自处理拒绝;此处仅避免未处理拒绝告警。
293
+ } else if (resolved.persistRestoreTable) {
294
+ warn('storageDomain not composed: the restore table is memory-only (lost on restart). Compose the storage stack (storage / storage-json / storage-domain) in the profile, or set persistRestoreTable: false.')
295
+ }
266
296
 
267
297
  const store = new RestoreStore({
268
298
  entities: resolved.entities,
@@ -277,30 +307,62 @@ export function apply(ctx, config = {}) {
277
307
  let runtimeEnabled = true
278
308
 
279
309
  // --- 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
- })
310
+ if (resolved.scope.includes(SCOPES.MESSAGES)) {
311
+ ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
312
+ if (!runtimeEnabled) return next()
313
+ const decision = await next()
314
+ if (decision.kind !== 'enter') return decision
315
+ const sessionId = agent?.session?.id
316
+ if (sessionId === undefined || sessionId === null || sessionId === '') return decision
317
+ const stripper = store.stripperFor(sessionId)
318
+ const before = stripper.stats()
319
+ const { messages: maskedMessages, replaced } = maskMessages(decision.messages, stripper)
320
+ if (replaced === 0) return decision
321
+ const after = stripper.stats()
322
+ const distribution = {}
323
+ for (const [label, count] of Object.entries(after.distribution)) {
324
+ const delta = count - (before.distribution[label] ?? 0)
325
+ if (delta > 0) distribution[label] = delta
326
+ }
327
+ void store.persist(sessionId)
328
+ maybeAppendSessionEvent(agent.session, SESSION_EVENTS.APPLIED, {
329
+ sessionId,
330
+ replaced,
331
+ distribution,
332
+ }, eventGate, warn)
333
+ return { kind: 'enter', messages: maskedMessages }
334
+ })
335
+ }
336
+
337
+ // --- tools/post-execute 遮罩(scope: tools):工具结果 text 块里的 PII 在回喂模型
338
+ // 与落盘前脱敏为占位符。工具入参本身无法在 pre-execute 改写(上游契约),故 tools
339
+ // 作用域落在结果这一可改写的模型可见面(见 ARCHITECTURE.md)。
340
+ if (resolved.scope.includes(SCOPES.TOOLS)) {
341
+ ctx.on('tools/post-execute', async (exec, result, next) => {
342
+ if (!runtimeEnabled) return next()
343
+ const decision = await next()
344
+ if (decision.kind !== 'accept') return decision
345
+ const sessionId = exec?.agent?.session?.id
346
+ if (sessionId === undefined || sessionId === null || sessionId === '') return decision
347
+ const stripper = store.stripperFor(sessionId)
348
+ const before = stripper.stats()
349
+ const { blocks: maskedBlocks, replaced } = maskContentBlocks(result.content, stripper)
350
+ if (replaced === 0) return decision
351
+ const after = stripper.stats()
352
+ const distribution = {}
353
+ for (const [label, count] of Object.entries(after.distribution)) {
354
+ const delta = count - (before.distribution[label] ?? 0)
355
+ if (delta > 0) distribution[label] = delta
356
+ }
357
+ void store.persist(sessionId)
358
+ maybeAppendSessionEvent(exec.agent?.session, SESSION_EVENTS.APPLIED, {
359
+ sessionId,
360
+ replaced,
361
+ distribution,
362
+ }, eventGate, warn)
363
+ return { kind: 'accept', content: maskedBlocks }
364
+ })
365
+ }
304
366
 
305
367
  // --- /mask 命令(Consumer)。
306
368
  if (resolved.registerCommand) {
@@ -373,6 +435,7 @@ export {
373
435
  RestoreStore,
374
436
  createStripper,
375
437
  maskMessages,
438
+ maskContentBlocks,
376
439
  makeEventGate,
377
440
  maybeAppendSessionEvent,
378
441
  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.4",
4
+ "version": "0.2.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/PerryLink/dsh-mask.git"
@@ -88,6 +88,7 @@
88
88
  },
89
89
  "peerDependencies": {
90
90
  "@deepseek-ai/cordis": "^4.0.1",
91
+ "@deepseek-ai/dsh-commands": "0.1.1-rc.2",
91
92
  "@deepseek-ai/dsh-session": ">=0.1.0-rc.8 <0.2.0",
92
93
  "@deepseek-ai/dsh-storage": ">=0.1.0-rc.8 <0.2.0",
93
94
  "@deepseek-ai/dsh-storage-domain": ">=0.1.0-rc.8 <0.2.0",
@@ -96,7 +97,6 @@
96
97
  "@deepseek-ai/schemastery": "^3.18.0"
97
98
  },
98
99
  "dependencies": {
99
- "typescript": "^5.9.0",
100
100
  "zod": "^4.4.3"
101
101
  },
102
102
  "devDependencies": {
@@ -113,7 +113,8 @@
113
113
  "@deepseek-ai/dsh-tools": "0.1.1-rc.2",
114
114
  "@deepseek-ai/schemastery": "^3.18.0",
115
115
  "@types/node": "^22.19.0",
116
- "oxlint": "0.18.1"
116
+ "oxlint": "0.18.1",
117
+ "typescript": "^5.9.0"
117
118
  },
118
119
  "scripts": {
119
120
  "typecheck": "tsc -p tsconfig.check.json",
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 工具规范结果。 */