# dsh-vault — Encrypted Credential Vault for DeepSeek Harness

[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![npm version](https://img.shields.io/npm/v/dsh-vault?color=cb3837&logo=npm)](https://www.npmjs.com/package/dsh-vault)
[![GitHub Release](https://img.shields.io/github/v/release/Ox0400/dsh-vault?logo=github)](https://github.com/Ox0400/dsh-vault/releases)
[![npm downloads](https://img.shields.io/npm/dm/dsh-vault)](https://www.npmjs.com/package/dsh-vault)
[![GitHub issues](https://img.shields.io/github/issues/Ox0400/dsh-vault)](https://github.com/Ox0400/dsh-vault/issues)
[![Listed: awesome-dsh-plugin](https://img.shields.io/badge/Listed-awesome--dsh--plugin-2ea44f)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin#L2869)
[![Listed: awesome-deepseek-harness](https://img.shields.io/badge/Listed-awesome--deepseek--harness-2ea44f)](https://github.com/Dominic789654/awesome-deepseek-harness#L903)

**English** | [中文](README-zh.md)

dsh-vault is a security-focused plugin for DeepSeek Harness that stores sensitive credentials — **usernames, emails, phone numbers, passwords, TOTP secrets**, and developer credentials like **SSH connections, API keys, secrets, and OAuth access/refresh tokens** — encrypted at rest, and exposes them to the model through CRUD, search, password generation, and TOTP tools, plus a Settings UI page.

## Security & Implementation

- **Zero external crypto dependencies**: everything is built on Node's built-in `node:crypto` (AES-256-GCM authenticated encryption, scrypt key derivation, RFC 6238 TOTP).
- **Master password**: every entry is encrypted with a 256-bit key derived via `scrypt(master password, salt)` and AES-256-GCM. The key never touches disk; after unlock it is cached in-process and re-derived on restart.
- **Tamper-evident**: GCM auth tags plus a fixed-plaintext verification envelope — a wrong master password or modified ciphertext fails immediately, never returning garbage.
- **No plaintext at rest**: the on-disk document contains no plaintext credentials; each entry uses an independent random nonce.
- **Atomic writes**: reuses the harness `writeFileAtomic` + file lock; in-process writes are serialized, cross-process writers take the lock.
- **Search never leaks**: `vault_search` returns summaries only (id/title/kind/username/email/phone/host/port/url/tags) — **never passwords, keys, tokens, or TOTP secrets**. Full credentials are readable only via explicit `vault_get` by id.

## Entry Model

Each record has a `title`, an optional `kind`, and any combination of fields:

| Field | Description |
|---|---|
| `kind` | `login` (default) / `ssh` / `api-key` / `secret` / `oauth` / `custom` |
| `username` / `email` / `phone` | Account identity |
| `password` | The password |
| `host` / `port` | SSH host and port (e.g. `db.internal` / `2222`) |
| `privateKey` | SSH private key (PEM) |
| `apiKey` | API key |
| `secret` | Generic secret (client secret, shared secret, …) |
| `accessToken` / `refreshToken` / `expiresAt` | OAuth token pair and expiry (epoch millis) |
| `otpSecret` | TOTP secret (bare Base32 or otpauth:// URI) |
| `url` / `notes` / `tags` | Metadata |
| `fields` | Arbitrary key/value pairs (e.g. `{"region": "us-east-1"}`), searchable |

## Tools

| Tool | Purpose |
|---|---|
| `vault_add` | Add an entry (any combination of fields; empty strings/arrays are ignored) |
| `vault_get` | Read a full entry by id (including all secrets) |
| `vault_search` | Search titles/categories/usernames/emails/phones/hosts/ports/URLs/notes/tags/custom fields (incl. numeric/boolean/nested values; whitespace-separated terms OR-match); optional `createdAfter`/`createdBefore` epoch-millis filters; returns secret-free summaries; `limit` must be an integer 1–100 |
| `vault_update` | Update fields by id (unprovided fields kept; empty string clears a field; `title` is renamable; `rotationDays: 0` clears rotation = never rotate) |
| `vault_compare` | Compare two entries field by field (`onlyA`/`onlyB`/`differ`/`equal`) — field names only, never secret values |
| `vault_rename` | Rename an entry in one call (shortcut for `vault_update`) |
| `vault_delete` | Soft-delete an entry (moves it to the trash, still encrypted on disk) |
| `vault_restore` / `vault_purge` / `vault_restore_recent` | Bring a trashed entry back / purge it / undo the last delete |
| `vault_lock` / `vault_unlock` | Explicitly lock the vault (wipe the in-memory key) / re-unlock it |
| `vault_totp` | Generate the current 6-digit code for a stored otpSecret (or a bare Base32 / otpauth URI) |
| `vault_generate_password` | Generate a strong random password (length/classes/grouping) **or a memorable passphrase** (`passphrase: true`, EFF-style word list, `words`/`separator`/`wordDigits`) |
| `vault_strength` | Zero-dependency password strength estimate (score 0–100, weak/fair/strong/very strong) |
| `vault_password_history` | List an entry's previous passwords (1Password/Bitwarden-style, newest first, capped at 10; current password excluded) |
| `kind: card` | Bank/credit-card entries: `cardNumber`/`cardExpiry` (MM/YY)/`cardCvv`/`cardHolder`; search summaries expose expiry + holder only (never the number or CVV); Bitwarden JSON export maps to a `card` item (type 3, brand inferred) |
| `vault_password_rollback` | Restore an entry password to a stored history entry (current password is archived first, so it is reversible) |
| `vault_recovery_code` / `vault_verify_recovery` / `vault_recovery_status` | One-time vault recovery code (1Password/Bitwarden-style): 32-char code shown once, only its SHA-256 hash is stored; verify possession of the code; check whether one is set |
| `vault_rekey` | Upgrade the vault to fresh scrypt KDF parameters in place |
| `vault_backup` | Timestamped encrypted backup with retention; optional `note` |
| `vault_import_csv` | Bulk-import credentials from a CSV file (custom columns become fields; `overwrite: true` merges fields into existing entries instead of duplicating) |
| `vault_bulk_delete` | Soft-delete entries matching a query/kind/tag or explicit ids; `confirm: true` required (dry-run by default); trashed entries are restorable |
| `vault_apply_tags` | Bulk add/remove/replace tags on every entry matching a query (dry-run supported, no secrets) |
| `vault_totp_uri` | Build an otpauth:// provisioning URI for a stored or bare TOTP secret |
| `vault_switch` / `vault_list` | Switch the active vault by name / list available vaults |
| `vault_rotation` | Report expired / due-for-rotation / expiring-soon credentials; `soonWindowDays` (1-90, default 7) tunes the soon horizon (no secrets) |
| `vault_health` | Vault health scan: weak/reused passwords, missing 2FA, insecure http:// sites, and an overall security score (0–100) |
| `vault_watchtower` | Watchtower-style per-entry risk analysis (1Password/Bitwarden-inspired): flags short/weak passwords, keyboard sequences, embedded years, common passwords, reuse, http:// sites, missing 2FA, expiry — with a 0–100 score and good/warn/poor verdict (no secrets); entry rows show ⚠ badges |
| `vault_breach_check` | Watchtower-style breach scan against Have I Been Pwned (k-anonymity: only the SHA-1 prefix leaves the machine), with an offline common-password fallback |
| `vault_integrity` | Verify the on-disk vault file decrypts correctly and matches the in-memory store |
| `vault_merge` | Merge one entry into another; `keepSource: true` keeps the source after merging |
| `vault_attach` / `vault_attachments` / `vault_attachment` / `vault_detach` | Attach files to entries (private keys, certs, configs, recovery codes) — stored base64 inside the encrypted entry, encrypted at rest; list names/sizes, read content, remove |
| `vault_quick_add` | Fast capture (title + one secret) with optional tags/notes |
| `vault_expiry` | Set/clear expiry (`expiresAt: 0` removes it) |
| `vault_stats` | Overview counts incl. `trashCount` (no secrets) |
| `vault_verify` | Verify one entry or audit every entry (`all: true`) for per-kind completeness, port/expiry sanity (no secrets) |
| `vault_duplicates` | Find duplicate groups: `mode` = `both` (default) / `title` / `content` (no secrets) |
| `vault_report` | Printable inventory with expiry/rotation columns and a stats footer (no secrets) |
| `vault_export` / `vault_import` | Portable encrypted backup/migration of the whole vault (separate export password) |
| `vault_backup` / `vault_backup_now` | Timestamped encrypted backup named `<vault>-backups-YYYY-MM-DD_HH-MM-SS-<hex>.json` (owning vault + date visible); retention pruning keeps the newest N |
| `vault_restore_backup` | Restore from a backup: `mode: "merge"` (default) copies the backup entries INTO the current vault so they appear in the entries list; `mode: "replace"` overwrites the whole vault with a safety snapshot first |
| `vault_vault_rename` / `vault_vault_delete` | Rename a named vault (file moves, active session follows) or permanently delete one (default vault protected) |
| `vault_match_url` | Find login entries matching a URL (Bitwarden/1Password-style: exact host, subdomain, parent domain, path-prefix; www./port normalization) with a 0–100 score — never returns the password |
| `vault_fill` | Find the entry matching a host/URL/username/title and return its credentials |
| `vault_env` | Render env-flagged entries (tags contain `env`) as `KEY=VALUE` lines — see [Environment export](#environment-export) |
| `vault_export_bitwarden` / `vault_import_bitwarden` | Bitwarden/Vaultwarden JSON interop (full field mapping, overwrite support) |
| `vault_import_bitwarden_encrypted` | Decrypt a Bitwarden password-protected JSON export (PBKDF2/Argon2id + HKDF → AES-256-CBC + HMAC) and import it; pass the export passphrase |
| `vault_import_manager_csv` | Password-manager CSV auto-detected by header: Bitwarden (`login_uri`/`login_username`/…), 1Password 8, Dashlane, NordPass, Keeper, LastPass (`fav`/`grouping`/`extra`); dryRun preview |
| `vault_import_kdbx` | KeePass KDBX: 3.1 and 4.x, AES-KDF or Argon2 (RFC 9106), AES-256-CBC or ChaCha20 payload, keyfile support |
| `vault_export_1password` | Export entries as a 1Password 1PUX archive (ZIP + export.data) for import into 1Password or re-import here; item categories map to login / credit card / API credential / server |
| `vault_import_1password` / `vault_import_1pif` | 1Password 1PUX (ZIP) and legacy 1PIF text exports |
| `vault_import_enpass` | Enpass JSON export (folders → tags, typed fields, TOTP) |
| `vault_import_keepass_xml` | KeePass 2.x XML export (plaintext or `********` masked values) |
| `vault_import_chrome` / `vault_import_keychain` | Import passwords from Chrome's Login Data (macOS keychain / Linux keyring or `peanuts` / Windows DPAPI) or the macOS Keychain (internet passwords `inet` by default — the ones that actually back website logins — or generic `genp` via `classes`; session cache + preview, no prompt spam); every file import supports `dryRun` preview |
| `vault_import_firefox` | Firefox profile import (logins.json + key4.db, NSS 3DES / PBES2-AES, primary-password aware) |
| `vault_search_system` | Search Chrome / Keychain for sites & usernames — never exposes passwords |
| `vault_session_open` | Open a real headed browser window at a URL so the user can log in manually (password, 2FA, captcha) — the portable way to capture login state for sites that block embedding |
| `vault_session_collect` | Collect every cookie of an open browser session (incl. HttpOnly) and save it as a `cookie` entry |
| `vault_session_import` | Save session cookies from pasted JSON (devtools export shape) or a raw `Cookie` header string — the no-browser alternative |
| `vault_session_import_file` | Import a Netscape cookie-jar file (curl `-b` / wget / browser-extension export; the same format `vault_session_export` writes) |
| `vault_session_list` | List saved login sessions with cookie counts, expired and expiring-in-7d counts (no values) |
| `vault_session_export` | Export a saved session as a `Cookie` header value, a Netscape cookie-jar file (curl `-b`), raw JSON (Playwright `addCookies` shape), or a ready-to-run Playwright snippet |
| `vault_session_close` | Close an open browser login session (collected cookies stay in the vault) |
| `vault_session_prune` | Remove expired cookies from a saved session (session cookies are kept); `preview: true` reports without writing |
| `vault_copy` | Copy an entry (secrets included) into another named vault |
| `vault_templates` | Built-in + user-defined templates (save/list/remove), KeePassXC-style; built-ins now include Wi-Fi, Server, Database, Identity, Bank account, Card (1Password-inspired) | Built-in + user-defined templates (save/list/remove), KeePassXC-style |

**Typical workflows**: store an SSH credential (`kind: ssh` + host/port/username/password or privateKey) and have the model `vault_search` for the host then `vault_get` the connection details; keep `api-key`/`oauth` entries for API-gateway access/refresh token rotation.

## Installation

dsh-vault is a **bundle** (a package declaring `dsh.bundle`): once installed into a profile, its `cordis.patch.yml` automatically inserts the `vault` plugin row (referenced by package name `dsh-vault`; the master password is injected via the `DSH_VAULT_PASSWORD` environment variable). The package ships a self-contained build script — git installs compile `lib/` automatically.

All four install paths below are **verified end-to-end** (install → bundle layer recognized → plugin activates with all 7 `vault_*` tools registered → real `vault_add`/`vault_get` round trip → uninstall removes the layer):

| Path | Command | Build needed | `allowBuilds` |
|---|---|---|---|
| npm | `add dsh-vault` | no (prebuilt `lib/`) | no |
| GitHub | `add github:Ox0400/dsh-vault#v0.1.1` | yes (`prepare`) | yes (first run) |
| local path | `add /abs/path/to/dsh-vault` | no (link to built source) | no |
| tarball | `add ./dsh-vault-0.1.1.tgz` | no (prebuilt `lib/`) | no |

### Option 1: Install from npm (easiest)

```sh
dsh plugin --profile web add dsh-vault
```

npm packages ship **prebuilt `lib/` artifacts** — no allowBuilds, no local compilation, install and go. Set the master password before launching:

```sh
export DSH_VAULT_PASSWORD='your strong master password'
```

### Option 2: Install from GitHub (pin a tag or commit)

```sh
dsh plugin --profile web add github:Ox0400/dsh-vault#v0.1.1
```

A git install fetches **sources, not built artifacts**, so the `prepare` script builds `lib/` at install time. pnpm ≥10 blocks git dependencies from running build scripts by default. The verified flow:

1. Run the `add` command — it fails with an `allowBuilds` error and prints the exact key to allow (the line containing the repo URL, including the resolved commit hash):

   ```text
   allowBuilds:
     dsh-vault@https://codeload.github.com/Ox0400/dsh-vault/tar.gz/<sha>: true
   ```

2. Append that exact key to the profile's `pnpm-workspace.yaml` (`$DSH_HOME/profiles/<name>/pnpm-workspace.yaml`):

   ```yaml
   packages:
     - .
   allowBuilds:
     dsh-vault@https://codeload.github.com/Ox0400/dsh-vault/tar.gz/<sha>: true
   ```

3. Re-run the `add` — pnpm now runs the `prepare` script, builds `lib/`, and installs.

**Pin a tag/commit** so a later upstream push cannot silently change what runs on install. Treat the allowance for what it is: permission to execute that package's code on your machine at install time — only grant it to sources you trust.

### Option 3: Install from a local path

```sh
dsh plugin --profile web add /absolute/path/to/dsh-vault
```

pnpm links the checkout into the profile; the bundle is recognized as long as `lib/` exists (run `pnpm build` in the checkout first if needed).

### Option 4: Install from a tarball

```sh
npm pack && dsh plugin --profile web add ./dsh-vault-0.1.1.tgz
```

The tarball ships prebuilt `lib/` artifacts, so no build step or `allowBuilds` is required.

`dsh plugin --profile web remove dsh-vault` uninstalls (removes both the dependency and the layer).

## Command line

The plugin hands the vault to the assistant. The bundled `dsh-vault` command
hands the same vault to shells and scripts, so a skill's child process can read
a secret **without the plaintext ever entering the model's context**.

### 0. First: which command can you actually type?

**Nothing provides a `dsh-vault` binary until the package is installed** — the
plugin is a Cordis bundle, and `dsh` has no plugin-subcommand registry (the
launcher parses only its own flags; `dsh plugin …` just forwards to pnpm, so
`dsh web exec …` hands its arguments to the web app and fails with
`error: too many arguments`). Pick your row before reading the examples:

| your setup | the command |
|---|---|
| **source checkout** (`git clone` + `pnpm build`) | **`pnpm vault`** — or `node lib/cli.js`, or `./lib/cli.js` |
| plugin **installed into a profile** | `pnpm dsh plugin --profile web exec dsh-vault` |
| **globally installed** (`npm i -g dsh-vault`) | `dsh-vault` |
| **nothing installed** | `npx dsh-vault` |

```sh
# source checkout — this repository, right now:
pnpm vault list

# after a global install (or via npx) the same commands are:
dsh-vault list
npx dsh-vault list
```

Every example below is written as `dsh-vault …`; substitute the form from your
row (`pnpm vault …` in a checkout).

### 1. What `env` is for

`get` fetches **one** value and needs no tag. `dsh-vault env` is for the other
case: a script that needs **several** secrets at once. It prints the entries you
tagged `env` as shell assignments, so the script picks them up itself:

```
$ dsh-vault env
DEMO_API_KEY='sk-demo-123'
OTHER_PASSWORD='pw-not-exported'
```

`eval "$(dsh-vault env)"` reads those assignments into the **current shell** —
the usual reason to reach for it:

```sh
eval "$(dsh-vault env)"          # KEY=VALUE lines become shell variables
echo "$DEMO_API_KEY"
```

Two things worth knowing:

- **Only `env`-tagged entries are exported.** With none tagged you get
  "no entries are tagged for environment export"; tag one in the UI (the
  entry's tags field) or with `vault_update { id, tags: ["env"] }`.
- The name is derived from the title and field (`DASHSCOPE` + `apiKey` →
  `DASHSCOPE_API_KEY`) unless the entry sets `envKeys` — see
  [Environment export](#environment-export).


**Names — there are only three, and two of them are the word `env` on purpose:**

| name | what it is | meaning |
|---|---|---|
| `env` | an entry **tag** | which entries `dsh-vault env` exports; nothing is exported without it |
| `env` | the **command** | prints exactly the entries carrying that tag |
| `envKeys` | an entry **field** (optional) | pin exact names, e.g. `["DASHSCOPE_API_KEY"]`; the old singular `envKey` is accepted as a one-element shorthand |

Nothing else is called `env*`: `list --json` / `show` report `envKeys` (the names
an entry exports — what `get` accepts) and `envTagged` (whether `env` includes it).

### 2. The commands

```sh
dsh-vault list                             # grouped: what `env` exports vs the rest
#   Exported by `env` (tag "env") — 1 entry:
#     a1b2c3d4-…  api-key   DASHSCOPE        → DASHSCOPE_API_KEY
#   Not exported (no "env" tag) — 1 entry; `get <name>` still works for it:
#     9f8e7d6c-…  api-key   Example Billing  → EXAMPLE_BILLING_API_KEY
dsh-vault get my-entry                     # the entry's primary secret, stdout only
dsh-vault get my-entry --field apiKey      # one named field
dsh-vault get my-entry --mask              # confirm it exists without printing it
dsh-vault show my-entry                    # non-secret metadata as JSON
dsh-vault env                              # env-tagged entries as KEY=VALUE
dsh-vault env --mask                       # human view, both sections as KEY=VALUE:
#   ## exported items
#   NPM_REGISTRY_API_KEY=npm_***
#   ## unexported items
#   EXAMPLE_BILLING_API_KEY=***
dsh-vault export-env .env                  # …or write them to a 0600 file
dsh-vault verify                           # check the master password, nothing on stdout
```

Secrets go to stdout and everything else (progress, errors) to stderr, so
`$(dsh-vault get …)` and pipelines behave. Exit codes: `0` ok, `1` runtime
error, `2` usage error.

### 3. Shell quoting: not `export $(…)`

`eval "$(dsh-vault env)"` is right; `export $(dsh-vault env)` is wrong. Command
substitution splits on whitespace and does **not** strip the quotes the CLI
emits, so a value containing a space breaks into the wrong words and every value
keeps its literal quotes:

```sh
$ export $(dsh-vault env); echo "$WITH_SPACE"
['pass]                     # split at the space
bash: export: `here': not a valid identifier
$ eval "$(dsh-vault env)"; echo "$WITH_SPACE"
[pass word here]            # read as intended
```

In a script, prefer the file form — no `eval` at all:

```sh
dsh-vault export-env .env && set -a && . ./.env && set +a
```

### 4. The master password

`--password-stdin`, then `$DSH_VAULT_MASTER_PASSWORD` (or `$DSH_VAULT_PASSWORD`),
then an interactive prompt that **hides what you type** (the terminal is switched
to raw mode; verify with `tests/e2e/cli-prompt.mjs`). It is **not** read from the plugin's config file, so
the CLI never depends on (or leaks) whatever the profile patch holds:

```sh
DSH_VAULT_MASTER_PASSWORD=… pnpm vault list
printf '%s\n' "$PASSWORD" | pnpm vault list --password-stdin
```

### 5. Install details

**Source checkout:**

```sh
git clone git@github.com:Ox0400/dsh-vault.git && cd dsh-vault
npm install && npm run build            # builds lib/, including executable lib/cli.js

pnpm vault list                         # the repo's own entry point (package.json script)
node lib/cli.js list                    # same thing, spelled out
./lib/cli.js list                       # the file is executable too
npm link                                # …or put a real `dsh-vault` on PATH
```

To develop the *plugin* against a profile you also link the checkout into the
profile and add a patch layer that inserts it (how this repository is normally
developed); **the CLI needs none of that** — it reads the vault file directly. A
hand-linked plugin has no `.bin/dsh-vault` shim, so `pnpm … exec` reports
`Command "dsh-vault" not found` unless you install it as a dependency
(`pnpm dsh plugin --profile web add dsh-vault`) or create the shim by hand:

```sh
ln -sf ../dsh-vault/lib/cli.js ~/.dsh/profiles/web/node_modules/.bin/dsh-vault
```

**npm install:**

```sh
pnpm dsh plugin --profile web add dsh-vault          # into a profile (plugin + CLI together)
pnpm dsh plugin --profile web exec dsh-vault list    # --profile is required

npm i -g dsh-vault && dsh-vault list                 # globally
npx dsh-vault list                                   # or not at all
```

The read commands (`list`, `get`, `env`, `show`, `verify`, `export-env`) depend
on Node alone, which is why the global and `npx` forms work. Only *writing* to a
vault needs the harness runtime, and the plugin always has it.

Two more caveats on the profile route: its `.bin` shim can be pruned by a later
`pnpm install`, and `pnpm dsh plugin --profile web add dsh-vault` reconciles
`dsh.profile.bundles` from the package's `dsh.bundle` declaration — if your own
patch layer *also* inserts the plugin, that mounts it twice, so remove the patch
row first.

## Privacy of the breach check

`vault_breach_check` (and the security page) can check passwords against Have I
Been Pwned. Exactly one thing leaves this machine:

```
GET https://api.pwnedpasswords.com/range/<FIRST 5 HEX CHARS OF SHA-1(password)>
Add-Padding: true
```

The response lists every suffix in that bucket (hundreds of hashes) and the match
is computed **locally** — the full hash, the password, and the entry it belongs to
never leave. A password already in the bundled common-password list is answered
offline and produces **no request at all**; repeat lookups of the same prefix are
cached in memory for an hour.

What k-anonymity does *not* hide, stated plainly:

- the **five-character prefix** (20 bits, one bucket in ~1M) goes to Cloudflare,
  together with your IP;
- an observer who can guess candidate passwords can compute each candidate's
  prefix and see whether you queried that bucket — that is the known limit of
  this protocol, not something the header fixes;
- `Add-Padding` only removes the *response-size* signal (without it, a bucket's
  response length correlates with how common the password is).

To keep even the prefix on your machine, pass `online: false` — the check then
uses only the offline list, which is far smaller, so treat it as a screening aid
rather than a verdict.

## Strength indicator

Each entry row shows a three-star indicator after its title — **☆☆☆ = 0 score,
★★★ = full marks** — in six half-star steps:

![strength indicator: the seven states](docs/strength-stars.svg)

A half star is a real half-filled star, not a `½` next to it: the coloured copy
of the same three glyphs is clipped to a percentage, and `★`/`☆` share an advance
width so the clip lands exactly on a star boundary.

| score | filled stars | band |
|---|---|---|
| 0–8 | 0 | weak |
| 9–24 | ½ | weak |
| 25–41 | 1 | weak |
| 42–58 | 1½ | fair |
| 59–74 | 2 | fair |
| 75–91 | 2½ | strong |
| 92–100 | 3 | very strong |

Hovering it shows the exact score and verdict. The score is computed **host-side**
(the list never receives the secret) and covers the entry's **password or card
PIN**; machine-generated API keys and private keys are deliberately not scored,
so every key does not sit at full marks.

## Environment export

Entries tagged `env` can be materialised as `KEY=VALUE` lines (`vault_env`,
`vault_export_env`), and their names follow the field names every toolchain
expects:

| Entry | Exported key |
|---|---|
| title `DASHSCOPE`, `apiKey` | `DASHSCOPE_API_KEY` |
| title `DASHSCOPE`, `prefix: APP_` | `APP_DASHSCOPE_API_KEY` |
| `envKey: "TAVILY_TOKEN"`, `accessToken` | `TAVILY_TOKEN` |
| …the same entry's `refreshToken` / `fields.scope` | `TAVILY_TOKEN_REFRESH_TOKEN` / `TAVILY_TOKEN_SCOPE` |

- The field suffix is vendor-standard: `apiKey → API_KEY`, `accessToken →
  ACCESS_TOKEN`, `refreshToken → REFRESH_TOKEN`, `privateKey → PRIVATE_KEY`,
  `password → PASSWORD`, `cardNumber → CARD_NUMBER`.
- Set an entry's **env-var names** (optional field `envKeys`, also via
  `vault_add`/`vault_update`, or comma-separated in the editor) when the derived
  names are not what your scripts expect. They are **positional** and used
  **verbatim** (no prefix, no title):

  | `envKeys` | exported |
  |---|---|
  | `["DASHSCOPE_API_KEY"]` | `DASHSCOPE_API_KEY=apiKey` |
  | `["GOOGLE_ACCESS_TOKEN", "GOOGLE_REFRESH_TOKEN"]` | `...=accessToken`, `...=refreshToken` |
  | `["MY_KEY"]` on an entry with 3 secrets | `MY_KEY`, `MY_KEY_REFRESH_TOKEN`, `MY_KEY_SCOPE` |

  Each name must be a POSIX identifier (`[A-Za-z_][A-Za-z0-9_]*`), at most 8,
  and no duplicates. `envKey` (singular string) is still accepted as a
  one-element shorthand and is folded into `envKeys` on write.
- Custom fields are exported too, as `<BASE>_<FIELD>`.

> Upgrading: keys changed from `DASHSCOPE_APIKEY` to `DASHSCOPE_API_KEY` in
> 1.10.64 to match vendor conventions. Set `envKey` on an entry to pin an exact
> name if a script depends on the old spelling.

## Tool profiles

The plugin ships 110+ model tools, but **registers only 11 core tools by default** (`vault_list/search/get/add/update/delete/fill/clipboard/totp/generate_password/strength`) to keep the model's tool catalog small and cheap.

Switch profiles in **Settings → Credentials → Permissions → Model tools** — it applies instantly (no restart) and is persisted per vault:

| Profile | Registers |
|---|---|
| **Basic** (default) | core only — everyday use |
| **Standard** | + management: favourites, tags, icons, expiry/rotation, health, duplicates/merge, attachments, templates, env masks |
| **Full** | everything, incl. bulk import/export, browser sessions, backups and vault-file operations |
| **Custom…** | core + any of: Management / Import-export / Browser sessions / Backups & files |

`tools: basic|standard|full|custom` can also be set in the plugin config; the UI choice wins and is stored in `<vault dir>/access.json`.

## Configuration

> [!WARNING] Security: never put the master password in plaintext
> A profile patch (`cordis.patch.yml` / `cordis.yml`) is a config file that is
> easy to commit to git, screenshot, or capture in logs — so a plaintext
> `masterPassword:` row leaks the key to your whole encrypted vault. Always use
> `masterPasswordEnv` and export the real password into the process/shell
> environment instead (e.g. `export DSH_VAULT_PASSWORD='…'`). See the
> [community-plugin-audit handbook](https://github.com/sandbaseai/deepseek-harness-handbook/blob/main/docs/en/security/community-plugin-audit.md)
> for the evidence-first checklist for credential-handling plugins.
>
> ```yaml
> # ✗ DON'T — plaintext master password in a config file
> - id: vault
>   name: dsh-vault
>   config:
>     masterPassword: 'my-secret'
> ```
>
> ```yaml
> # ✓ DO — reference an environment variable; keep the file itself 0600
> - id: vault
>   name: dsh-vault
>   config:
>     masterPasswordEnv: DSH_VAULT_PASSWORD
> ```

| Option | Description |
|---|---|
| `masterPassword` | The master password inline (appears in cordis.yml; not recommended) |
| `masterPasswordEnv` | Environment variable name holding the master password (recommended) |
| `path` | Vault file path; defaults to `$DSH_HOME/vault/default.json` |
| `name` | Vault name for the default path (e.g. `name: work` → `$DSH_HOME/vault/work.json`) |
| `accessMode` | Access policy for the model tools. Three states: `readonly` (mutations rejected on tools + UI), `ask` (default — reads free, every add/update/delete goes through the harness approval channel so the user confirms each write), or `auto` (automatic read-write, no per-call prompt). The Settings UI offers this exact three-way choice and persists it to `<vault dir>/access.json`. |
| `autoCapture` | `false` (default). When `true`, the system prompt instructs the model to detect credentials shared in conversation and — per user preference — offer to save them with `vault_add`. |
| `lockTimeoutSeconds` | Auto-lock: after this many seconds of inactivity the vault re-locks (key wiped) and every read/write requires `vault_unlock`. `0`/absent disables. |
| `exportPasswordEnv` | Environment variable holding the export/import password for `vault_export`/`vault_import` (never pass it as a model argument). |
| `backupRetention` | How many encrypted backups to keep (default 10); `vault_backup` prunes older copies. |

Example:

```yaml
- id: vault
  name: dsh-vault
  config:
    masterPasswordEnv: DSH_VAULT_PASSWORD
    accessMode: ask
    autoCapture: true
```

With `autoCapture: true`, when you share a credential in chat (e.g. "my npm token is npm_…"), the assistant offers to store it; on your consent it calls `vault_add` immediately. With `autoCapture` off, credentials are only saved when you explicitly ask. The Settings UI shows the current mode (read-only / ask-before-write / automatic read-write) with a dropdown to switch it, an **auto-capture toggle** (detect credentials shared in chat → offer to save), a kind filter, a health & rotation summary, a trash view, and masked secret fields with a Show/Hide toggle.

The vault is created automatically on first tool use; every launch re-unlocks with the master password. **Forgetting the master password = permanent data loss** (no backdoor — by design).

## Development

Clone and develop locally:

```sh
git clone git@github.com:Ox0400/dsh-vault.git
cd dsh-vault
pnpm install    # installs devDependencies (typescript/tsdown/vitest, …)
pnpm build      # builds host lib/*.js and the browser bundle lib/client.js
pnpm test       # runs the 420 vitest tests
```

> Tests need harness peer packages such as `dsh-llm`/`dsh-system-prompt`; inside the harness monorepo these resolve via workspace links.

Common commands:

```sh
pnpm test          # unit + integration tests (vitest, 420)
pnpm typecheck     # tsc -p tsconfig.json --noEmit
pnpm build         # = build:host (tsc) + build:client (tsdown)
npm pack           # optional: tarball for `dsh plugin add ./dsh-vault-0.1.1.tgz`
```

All 420 tests pass (crypto / TOTP / password generation / store CRUD / gateway / integration).

Browser checks run against a real `dsh web` instead of vitest, and every one of
them refuses to operate on the default vault:

```sh
node tests/e2e/theme-check.mjs      # dual-theme tokens, ring track, attach row
node tests/e2e/polish-check.mjs     # empty-state copy, relative-age hover hints
node tests/e2e/contrast-audit.mjs   # WCAG sweep over all eight tabs, both themes
```

See `tests/e2e/README.md` for the safety rules and for the two traps that make a
contrast audit lie (serialised `color(srgb …)` values, and `opacity` counting as
part of the colour).

## Keeping credentials out of public content

Everything in this repository is public — the npm tarball, the GitHub releases,
the issue and discussion posts. Before publishing, `pnpm scan:secrets` checks
the working tree, the tracked files and the commit messages for anything that
looks like a credential, and `pnpm scan:public` additionally checks every
release and comment already posted. The local half also runs in `pnpm test`, so
a committed secret fails CI rather than shipping.

## Theming

The UI reads the host's design tokens instead of hard-coded colours. A local
semantic layer maps onto the real `--dsw-alias-*` namespace, with literal
fallbacks so the page still renders outside DeepSeek Harness:

```css
--v-text: var(--dsw-alias-label-primary, #1f2328);
--v-border: var(--dsw-alias-border-l2, #d9d9d9);
--v-success-text: color-mix(in srgb, var(--v-success) 55%, var(--v-text));
```

Because the aliases flip with `body[data-ds-dark-theme]`, light and dark follow
the host automatically — there is no second stylesheet and no theme prop.

Two rules the layer exists to enforce:

- **Never use `state-*-primary` as body text.** Those tokens are tuned for fills
  and icons: on white they measure 2.28:1 (success) and 2.15:1 (warn), far below
  WCAG AA. The `--v-*-text` variants mix them towards the theme's own text colour,
  so one declaration darkens on light and lightens on dark (light 5.8/5.6/7.5:1,
  dark 12+/11+/6.5:1).
- **Never dim text with `opacity`.** `opacity: .8` turns a 5.8:1 colour into
  3.9:1. Express "secondary" with a token (`--v-text-2`), not with transparency.

`tests/e2e/contrast-audit.mjs` verifies both across every tab in both themes.

## Packaging & Publishing

This package is a standard npm bundle:

- `dsh.bundle.patch` → `cordis.patch.yml` (the layer applied automatically when a profile lists this bundle)
- `dsh.client` → browser-side declaration (`exports["./client"]` points at `lib/client.js`)
- `prepare` script → self-contained build on git install (`tsc` host + `tsdown` client)
- Runtime dependencies are all `peerDependencies` (provided by the host harness — no duplicate instances)

Distribution options:

```sh
npm pack                  # tarball → dsh plugin add ./dsh-vault-0.1.1.tgz
npm publish --access public   # registry → dsh plugin add dsh-vault
```

## Security Boundaries & Known Limitations

- Vault strength is bounded by master-password strength; use ≥ 16 characters of high entropy.
- scrypt cost parameters (N=32768, r=8, p=1) are persisted in the document and can be raised in future versions; old documents remain decryptable.
- Plaintext credentials exist only in process memory and during explicit `vault_get` reads; `vault_search`/`vault_update` outputs never contain passwords, keys, or tokens. Secrets returned by `vault_get` enter that tool call's result (model context) — callers should avoid repeating them in conversation.
- This plugin targets single-machine / personal deployments; team-shared vaults are out of scope.

## Listed in

- [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin#L2869) — official curated directory (Security & Safety section)
- [awesome-deepseek-harness (Dominic789654)](https://github.com/Dominic789654/awesome-deepseek-harness#L903) — community directory (Security section)

Submissions pending maintainer review: [Anil-matcha/awesome-dsh-plugin #127](https://github.com/Anil-matcha/awesome-dsh-plugin/pull/127) · [0xsline/awesome-deepseek-harness #563](https://github.com/0xsline/awesome-deepseek-harness/pull/563) · [dsh-handbook #65](https://github.com/Electricitysheep/dsh-handbook/pull/65)
