Bundle
dsh-pii-anonymizer
Deterministic, one-way PII anonymizer at the start of the DeepSeek Harness agent loop
- Source
- JWE24-code
- License
- MIT
- Updated
- Updated 8 days ago
Readme
# dsh-pii-anonymizer
[](https://dshfind.com/plugins/JWE24-code/dsh-pii-anonymizer)
[](https://github.com/JWE24-code/dsh-pii-anonymizer/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/dsh-pii-anonymizer)
[](LICENSE)
A native [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin
that puts a **deterministic, one-way PII shield at the start of the agent loop**.
Your prompt is scanned by fixed rules and checksums (never by a model) and every
detected value is replaced with a stable placeholder before the message is
committed and sent. The real values live only in process memory β never in the
session log, never on disk, never on the wire. When something is shielded, a
local notice tells you.
```
you type: email a@x.com or call +31 6 1234 5678
model sees: email [[EMAIL_1]] or call [[PHONE_1]]
you see: π PII shield: 2 item(s) anonymized locally (1 EMAIL, 1 PHONE).
Real values did not leave this computer.
```
Anonymization is intentionally **irreversible**: there is no reverse map and no
restore path, so nothing in the system can put the real values back.
## Where it hooks
`agent/pre-step` β the loop's step-admission waterfall
(`@deepseek-ai/dsh-agent`). A listener calls `next()`, maps the returning
`decision.messages` through the anonymizer, and returns a decision whose
`messages` carry the placeholders. That is the last moment before the accepted
user batch is committed and sent to the provider.
### Why a native plugin, not a hook
The Claude Code / Codex hook bridges **cannot** do this. `HookOutput` in
`@deepseek-ai/dsh-hook-protocol` exposes only `additionalContext`; its
`updatedInput` is documented as *"PARSED but NOT honored."* There is no
prompt-rewrite channel. Only a native Cordis plugin on `agent/pre-step` can
replace the messages that enter the model request.
## Detection scope
Deterministic means *structural*. The detectors find things with a grammar or a
checksum:
| Type | Trigger | Validation |
| --- | --- | --- |
| `API_KEY` | known prefixes (`sk-`, `sk-ant-`, `AKIAβ¦`, `ghp_β¦`, `github_pat_β¦`, `xoxβ¦`, `AIzaβ¦`) | β |
| `IBAN` | `CC00β¦`, compact or space-grouped | **mod-97 checksum** |
| `CREDIT_CARD` | 13β19 digits, spaces/dashes allowed | **Luhn checksum** |
| `SSN` | `DDD-DD-DDDD` | none |
| `EMAIL` | RFC-ish address shape | none |
| `IPV6` / `IPV4` | address shapes | octet range for IPv4 |
| `PHONE` | digit run with `+ ( ) . - space`, optional leading `(`, 7β15 digits | rejects ISO dates and version strings |
Free-form **names, postal addresses, and other context-dependent PII are not
detected** β a deterministic rule cannot catch them without unacceptable false
positives. Adding an ML/NER stage would break the "deterministic" property.
Overlaps are resolved by priority (checksum types beat the loose phone rule),
then span length, then position.
## Pseudonymization model
`PiiPseudonymizer` keeps, per session:
- `forward`: original value β placeholder (stable across turns, so the model can
keep referring to `[[EMAIL_1]]` consistently)
- per-type ordinals, so tokens read `[[EMAIL_1]]`, `[[PHONE_2]]`, β¦
There is **no reverse map**. The originals are held only in volatile memory for
the life of the process, so they cannot be restored by anyone, including this
plugin. This is a deliberate trade: the shield is safe by construction at the
cost of the transcript showing placeholders.
### Scope
Only **human user messages** (`source.kind === 'user'`) are rewritten.
Harness-injected context β runtime context, skill catalogs, tool results β is
left untouched, because it is not the user's prompt and redacting it can clobber
values the agent legitimately needs (for example a sandbox host IP).
## Configuration
```yaml
- id: pii-anonymizer
name: dsh-pii-anonymizer
config:
enabled: true # master switch
notify: true # emit the local notice when something was shielded
debug: false # log load/shield events (counts only, never values)
```
## Installation (as a bundle)
This package is a DSH **bundle**: `package.json` declares `dsh.bundle.patch`, and
`cordis.patch.yml` inserts the `pii-anonymizer` row. A profile lists it in both
`dependencies` and `dsh.profile.bundles` β which is what the moqi-tui `/plugins`
picker reads (it reads the manifest, not the Cordis loader).
Install it from the registry into a profile:
```sh
npm install dsh-pii-anonymizer # inside ~/.dsh/profiles/<name>
# or, from the terminal app:
/plugins add dsh-pii-anonymizer
```
`tools/register-profile.mjs <profileDir>` makes the manifest edit for a local
checkout, and writes a `package.json.bak` beside it:
```sh
node tools/register-profile.mjs ~/.dsh/profiles/tui
```
The package must also be resolvable from the profile. On this machine it is
symlinked into the shared resolution root:
```
~/.dsh/profiles/node_modules/dsh-pii-anonymizer -> /home/joeri/Projects/dsh-pii-anonymizer
```
**Install status:** registered and enabled in all six profiles (`dev-tui`,
`headless`, `jev-dev`, `tui`, `tui-dev`, `web`). Restart the app to load it.
Rollback per profile: restore its `package.json.bak`.
## Development
```sh
node --test # 16 detector + pseudonymizer + message-scope unit tests
```
`node_modules/@deepseek-ai` is a dev-only symlink into the local harness install
so `src/index.mjs` resolves its peer dependencies for module-load smoke tests.
An installed plugin resolves them from the profile instead.
### End-to-end test (no credentials, no network)
`tools/mock-provider.mjs` is a tiny OpenAI-compatible SSE server; `tools/e2e.patch.yml`
routes the deepseek adapter at it and loads this plugin. A real harness loop then
runs against the mock, and `tools/requests.jsonl` records exactly what the
provider received.
```sh
# 1. throwaway harness home inside this project (never ~/.dsh)
DSH_HOME=$PWD/../.dsh-scratch dsh --profile pitest \
--from-default-profile headless --dump-config
# 2. mock provider
node tools/mock-provider.mjs & # writes tools/requests.jsonl
# 3. one real turn
DSH_HOME=$PWD/../.dsh-scratch DSH_E2E_KEY=dummy \
dsh --profile pitest --patch tools/e2e.patch.yml \
"My email is jane.doe@corp.example and my IBAN is DE89 3704 0044 0532 0130 00"
# 4. confirm the provider only ever saw placeholders
grep -o '\[\[[A-Z_0-9]*\]\]' tools/requests.jsonl | sort -u
```
Verified output: the provider received `My email is [[EMAIL_1]] and my IBAN is
[[IBAN_1]]` plus the `notice`; the raw email and IBAN appear nowhere.
## Status
- [x] Deterministic detectors with checksum validation
- [x] Per-session stable pseudonymization (one-way, no reverse map)
- [x] Human-message scoping (injected context left intact)
- [x] `agent/pre-step` wiring β unit-simulated **and** run through a real
headless harness against a mock provider
- [x] Local `notice` to the user
- [x] Installed as a bundle in all six profiles, listed and enabled in the
`/plugins` picker
Install
dsh plugin --profile web add github:JWE24-code/dsh-pii-anonymizer
Profile: web
With the hub plugin installed, ask your agent to install it by name β it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-pii-anonymizer from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.