dsh-vault — Encrypted Credential Vault for DeepSeek Harness
English | 中文
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) |
|
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)
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:
export DSH_VAULT_PASSWORD='your strong master password'
Option 2: Install from GitHub (pin a tag or commit)
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:
-
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):
allowBuilds:
dsh-vault@https://codeload.github.com/Ox0400/dsh-vault/tar.gz/<sha>: true
-
Append that exact key to the profile's pnpm-workspace.yaml ($DSH_HOME/profiles/<name>/pnpm-workspace.yaml):
packages:
- .
allowBuilds:
dsh-vault@https://codeload.github.com/Ox0400/dsh-vault/tar.gz/<sha>: true
-
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
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
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 |
# 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:
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.
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
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:
$ 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:
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:
DSH_VAULT_MASTER_PASSWORD=… pnpm vault list
printf '%s\n' "$PASSWORD" | pnpm vault list --password-stdin
5. Install details
Source checkout:
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:
ln -sf ../dsh-vault/lib/cli.js ~/.dsh/profiles/web/node_modules/.bin/dsh-vault
npm install:
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
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
for the evidence-first checklist for credential-handling plugins.
# ✗ DON'T — plaintext master password in a config file
- id: vault
name: dsh-vault
config:
masterPassword: 'my-secret'
# ✓ 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:
- 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:
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:
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:
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:
--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:
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
Submissions pending maintainer review: Anil-matcha/awesome-dsh-plugin #127 · 0xsline/awesome-deepseek-harness #563 · dsh-handbook #65