dsh-ssh-vault
Encrypted SSH host vault for the DeepSeek Harness: the agent works with host aliases, while the address, port, account, and password stay in an AES-256-GCM vault whose key is wrapped by Windows DPAPI.
- Stars
- 0
- Language
- JavaScript
- Created
- Oct 16, 2026
- Updated
- Oct 16, 2026
Introduction
dsh-ssh-vault
English | 简体中文
Encrypted SSH host vault for the DeepSeek Harness: the agent works with host aliases, while the address, port, account, and password stay in an AES-256-GCM vault whose key is wrapped by Windows DPAPI.
A Host plugin for the DeepSeek Harness that lets an agent drive SSH without ever holding the credentials.
The model sees vm-centos; it never sees 192.0.2.10, and it cannot read the password back out of the vault.
Typical setup: a Windows machine managing local Linux VMs, out-of-band management interfaces, and Windows targets.
- Zero runtime dependencies — it drives the system OpenSSH client and nothing else.
- A plaintext password never reaches the disk. It is decrypted in memory, handed to
sshthroughSSH_ASKPASS, and dropped when the connection ends. - Failure attribution that names the layer — local key problem / public key not installed / wrong password / firewall drop are reported as separate conclusions.
- Long tasks that do not hold a tool call — remote detach (
detach) and condition waiting (wait_for), so an ssh call stops being a poller.
Note on language: tool descriptions and the configuration surface are English. The diagnostic notes the plugin prints at runtime are currently Chinese; internationalising them is tracked work.
Tools
| Tool | Purpose |
|---|---|
ssh_hosts | Manage vault entries: list / show / add / update / remove |
ssh_run | Run a command; supports sudo, run_in_background, detach, wait_for, bindAddress |
ssh_transfer | Upload and download over SCP, with the same bindAddress support |
ssh_probe | Connectivity self-check that adapts its commands to the target's shell |
Install
Package name dsh-ssh-vault, bundle row id ssh-vault.
Requirements
- Windows 10 1809+ / Windows 11 with the OpenSSH Client enabled.
Check with
ssh -V— it should printOpenSSH_for_Windows_9.x. If missing: Settings → System → Optional features → Add a feature → OpenSSH Client. - DSH Desktop with
dsh.bundle.patchsupport (the 0.2.0-rc series is verified).
Option A — install from a directory (recommended)
- Copy the package directory to the target machine, for example
D:\dsh-plugins\dsh-ssh-vault. - Install it, by asking the agent or through the plugin manager UI:
plugin_manager action=install_bundle target="D:\dsh-plugins\dsh-ssh-vault"
- Ask the agent to run
ssh_hosts action=list; a result means the plugin is live.
Option B — install from an archive
Copy dsh-ssh-vault-<version>.tgz (or .zip) over, unpack it, then follow Option A.
The tgz can sometimes be used directly as the install target; if it is rejected, unpack it first.
Local development
The profile links to this directory, so reinstall after changing files:
plugin_manager action=install_bundle target="<path to this checkout>"
Restart DSH Desktop after editing the source. The profile's
patchReloadislive, so changing onlyconfigincordis.patch.ymltakes effect immediately. Butindex.jsandlib/*.jsare loaded JavaScript modules: reinstalling the bundle — even removing and installing it again — does not replace the module generation in memory.
Security model
What is on disk
The vault lives in %DSH_HOME%\ssh-vault:
| File | Contents | Plaintext secrets? |
|---|---|---|
vault.json | Host inventory ciphertext (AES-256-GCM: iv / tag / data) | No |
key.json | The 32-byte data key, wrapped by Windows DPAPI for the current user | No |
known_hosts | Host public keys; host names are hashed via HashKnownHosts | No |
askpass.cmd / askpass.js | Password-injection helper; fixed content | No |
Address, port, account, and password all live inside the vault.json ciphertext. The data key is wrapped
again by DPAPI, so copying the directory to another machine or another Windows user does not let anyone
decrypt it.
Key protection
Chosen by keyProtection when the vault is first created and recorded in key.json:
dpapi(default) — the data key is protected by DPAPI; the boundary is "the current Windows user".file— the data key is stored as base64 inkey.json; the boundary degrades to filesystem permissions.passphrase— the data key is derived frommasterPasswordwith scrypt; no key material is stored.
If DPAPI is unavailable (for example a restricted sandbox that forbids child processes), the plugin falls
back to file and says so, rather than failing silently.
How the password reaches ssh
Windows OpenSSH 9.x supports SSH_ASKPASS_REQUIRE=force: with no terminal it asks the program named by
SSH_ASKPASS. The plugin generates askpass.cmd, which writes the password to stdout. The password
therefore exists only in that one ssh child process's environment — never on a command line (so it cannot
be seen in a process list) and never on disk.
Explicit boundaries
- Not protected against: a malicious process already running as the current Windows user. It can read the DPAPI key and the process environment alike. That is the inherent limit of a local threat model.
- Session log: tool arguments are written to the session record. The
passwordparameter ofssh_hoststherefore enters the log before it is encrypted. To avoid that entirely, use the offline entry script orcredentialRef— and read the trade-offs below, becausecredentialRefis not free. - The
hostargument ofssh_hosts addlikewise enters the session log; recording a host cannot avoid that. Everyday calls afterwards use the alias only.
Usage
# Record a host (the password enters the session log here, then is encrypted)
ssh_hosts action=add alias=vm-centos host=192.0.2.10 port=22
username=root password=... description="Local CentOS VM" tags=["vm","centos"]
# Everyday use — alias only
ssh_probe host=vm-centos
ssh_run host=vm-centos command="systemctl status sshd"
ssh_transfer host=vm-centos direction=upload local=./dist/app.tar remote=/tmp/app.tar
ssh_hosts list masks addresses and accounts by default (192.0.*.*, r***); pass reveal=true to see
the full values. The password is never returned under any circumstances — show only reports
set (stored encrypted).
Long tasks: remote detach + condition waiting
For anything measured in minutes — upgrades, disk work, scans — use detach to run the command on the
remote host, detached from the SSH session:
ssh_run host=kali command="apt -y full-upgrade" sudo=true detach=true
→ [dsh-ssh-vault] started in the background on the remote host: pid=6676
[dsh-ssh-vault] log: /tmp/.dsh-ssh-<token>.log
[dsh-ssh-vault] liveness: ps -p 6676 >/dev/null 2>&1 && echo ALIVE || echo GONE
The local call returns in about a second, and the remote process is independent of it: a dropped link, a cancelled call, or a proxy timeout all leave it running. This is more reliable than a long-lived local connection — the pid and the log stay on the remote host, ready to inspect later.
Then do not poll with sleep; wait once with wait_for:
ssh_run host=kali wait_for={path: "/tmp/dsh-upgrade.log", regex: "Upgrade complete", timeoutMs: 900000}
On success it returns the matching line; on timeout it exits 124 and says so. The wait can itself go to
the background:
ssh_run host=kali wait_for={path: "/var/log/upgrade.log", regex: "complete"} run_in_background=true
Having the command write its own completion marker makes the condition most reliable:
command="...; echo DONE > /tmp/job.done" followed by wait_for={path: "/tmp/job.done"}. A full
"start + wait" flow is then two calls instead of a dozen sleeps.
Privileged work: sudo without the conversation
Do not write echo <password> | sudo -S — that puts the password into the tool arguments, the session log,
and the remote ps. Use sudo: true instead:
ssh_run host=kali command="apt update && apt -y full-upgrade" sudo=true
The plugin takes the password from the vault and primes the sudo credential cache through SSH's standard input. The password therefore does not appear in:
- tool arguments or the session log (the call only says
sudo: true); - the local command line or the
sshprocess arguments; - the remote command line or the remote
psoutput (it travels on stdin and is never written down).
When the host has no separate password, the login password is reused. When the sudo password differs, store
it with ssh_hosts action=update sudoPassword=.... A failed prime reports "the stored password may not be
this account's sudo password, or sudoers requires a tty" rather than leaving you guessing.
If a command writes
sudo -Swhilesudo: trueis not set, the result carries a hint. When sudo fails to obtain a password (a terminal is required), the diagnosis names it — and warns about the pipeline trap: the exit code ofsudo ... | grep ...comes from the last stage, so a failed sudo can still return 0 and look like "empty output".
Passwords that never pass through the model: offline entry (recommended)
ssh_hosts add password=... writes the password into the session log. credentialRef avoids the
conversation but puts the password into a plaintext YAML file. You do not have to choose. Enter it
from your own terminal, straight into the encrypted vault:
cd <package directory>
node scripts/set-secret.mjs kali # interactive, no echo
node scripts/set-secret.mjs kali --sudo # set a separate sudo password
node scripts/set-secret.mjs kali --show # inspect state only
Where the plaintext exists along this path:
| Stage | Where the plaintext is |
|---|---|
| Entry | Only in your terminal (no echo); never through DSH, the model, or the session log |
| At rest | vault.json ciphertext (AES-256-GCM); the data key is wrapped by DPAPI |
| In use | Decrypted in memory, handed to ssh via askpass, discarded afterwards |
So there is no need to "write it to disk and encrypt it back later": the plugin already decrypts only when needed. Introducing a temporary plaintext file would add an exposure window, not remove one.
Set $env:DSH_HOME before running the script if the harness uses a custom data directory, otherwise it
will look in ~/.dsh.
Passwords that never pass through the conversation: credentialRef
Keep only the credential name in the host record and let the Harness credential store supply the value:
# 1) Add a line under refs in %DSH_HOME%\.credentials.yaml (or use the Harness credentials UI).
# Quote the value: a bare number is parsed as an integer by YAML, and : # @ need quoting anyway.
refs:
KALI_SSH_PASSWORD: "<password>"
# 2) Keep only the reference in the host record; setting it clears the vault's own copy
ssh_hosts action=update alias=kali credentialRef=KALI_SSH_PASSWORD
The model and the conversation never see the password; ssh_hosts show reports only credentialRef: ...
and password: missing.
Two limits worth knowing
- That file is plaintext YAML.
%DSH_HOME%\.credentials.yamlsits next toDEEPSEEK_API_KEYunencrypted. SocredentialRefbuys "no password in the conversation", not "encrypted at rest" — the two are mutually exclusive here: the vault's ownpasswordis ciphertext but passes through the conversation on entry;credentialRefavoids the conversation but lands in plaintext. The offline entry script satisfies both.- The provider reads credentials at startup. After adding or editing
refsyou must restart DSH Desktop; until then the host reports the reference as unavailable.A stale reference does not brick the host. If
credentialRefis unavailable but the vault still holds a password, the plugin falls back to it and says so in the result: "…is currently unavailable; this call fell back to the password stored in the vault." An environment problem therefore no longer looks like an authentication failure, and a running background task is not interrupted. It only errors when there is no password to fall back to either.
Out-of-band management: binding the source address
When a rule change would cut the management path, preset a source address on the host, or override it per call:
ssh_hosts action=update alias=kali bindAddress=192.0.2.150
ssh_run host=kali command="ip addr add 192.0.2.150/24 dev eth0" bindAddress=192.0.2.150
ssh uses -b; scp uses -o BindAddress=.
Windows targets and restricted environments
Set the shell to cmd or powershell
Windows' login shell is cmd.exe, where POSIX commands and multi-line scripts do not work:
ssh_hosts action=update alias=win-box shell=powershell
The plugin then feeds the command through powershell -NoProfile -NonInteractive -Command - on standard
input, and ssh_probe switches to whoami / version / boot time instead of failing on uname.
Observed:
cmdechoes its version banner and prompt (Microsoft Windows [Version ...],C:\Users\...>) when reading commands from a pipe. It works, but the output is noisy. Usepowershellwhen you want clean output — its self-check reads likeidentity: user@WIN10/os: Microsoft Windows 10 Enterprise LTSC.
For real work, upload a .ps1 and run it with powershell -File, treating ssh_run as the transport. Two
known encoding traps:
- A
.ps1containing non-ASCII text must be UTF-8 with BOM, or PowerShell 5.1 parses it as ANSI and reportsUnexpected token. - If console output is garbled, set
[Console]::OutputEncoding = [Text.Encoding]::UTF8andchcp 65001at the top of the script.
Private keys: a public key cannot log in, and permissions must be tight
privateKeyPathis validated at save time: a.pubfile, a PuTTY.ppk, or a file that is not a key at all is rejected immediately, instead of surfacing later as an authentication failure.UNPROTECTED PRIVATE KEY FILE/Permissions for ... are too openmeans the ssh client refused to load the key itself — it has nothing to do with the account or password on the server. The plugin reports it as the first conclusion rather than blaming the password.- Where a restricted sandbox forbids changing ACLs, copy the key into the vault directory
(
%DSH_HOME%\ssh-vault) and point the host record at the copy.
Where Windows administrators' authorized_keys live
Windows OpenSSH applies Match Group administrators:
__PROGRAMDATA__/ssh/administrators_authorized_keys
For an administrator account the public key must go in that file (which must be readable only by
Administrators and SYSTEM). Putting it in the user profile's .ssh\authorized_keys has no effect. The
plugin states this explicitly when it receives Permission denied (publickey...).
Readable error paths
ssh escapes non-ASCII paths into forms like E:\\ai project\\\346\223\215\344\275\234\\id_rsa. The plugin
decodes those octal escapes back to UTF-8, so the message is readable.
known_hosts and user configuration
The plugin always uses known_hosts inside the vault directory (with HashKnownHosts on) and never
writes ~/.ssh, so the sandbox error Could not create directory 'C:\Users\...\.ssh' cannot come from
it. Use knownHostsFile to relocate it, or sshConfigFile to point at — or (none) to suppress — the
user-level ssh configuration.
Pipelines that hide output
Output from ssh ... | grep -v ... stays in the remote pipeline buffer; while the command hangs, nothing
comes back. Workarounds:
- use
detachfor long commands; - add
stdbuf -oLon the remote side when you need filtered output live (stdbuf -oL journalctl -f | grep -v cron); - split into smaller commands, one call each, or write results to a remote file and read it back.
Configuration
Override the ssh-vault row's config in the profile's cordis.patch.yml (config is replaced wholesale,
not deep-merged):
- id: ssh-vault
config:
keyProtection: dpapi # dpapi | file | passphrase
masterPassword: '' # only for passphrase mode
vaultDir: '' # defaults to %DSH_HOME%\ssh-vault
knownHostsFile: '' # defaults to <vaultDir>\known_hosts
sshConfigFile: '' # passed to ssh -F
sshExecutable: ssh
scpExecutable: scp
connectTimeoutSec: 10
defaultTimeoutMs: 120000
maxOutputBytes: 200000
maskHostAddress: true # false makes list/show reveal addresses by default
Troubleshooting
The plugin attributes failures in order — local key → server authentication → transport — and only says "wrong account or password" once the earlier causes are excluded.
- Local key permissions /
.pubused as a key / PPK: see "Private keys" above. Permission denied (publickey): the public key is not installed on the target; see the Windows administrator note above.Permission denied, please try again.: wrong password — fix it withssh_hosts action=update.Too many authentication failures: the client tried too many keys; use an explicit password orprivateKeyPath.- Network unreachable / connection refused: check the subnet and firewall. If
bindAddressis set, confirm the source address is still on an interface — an address added manually withip addr addis lost on DHCP renewal. - Connection closed during handshake: usually a firewall or security group dropping packets, not authentication.
- Host key changed (target rebuilt): delete its entry from the vault's
known_hostsand reconnect. - Remote shell missing: set
shell: /bin/shfor Linux, orcmd/powershellfor Windows. key.jsoncannot be decrypted: the key is bound to the Windows user that created it. If the inventory can be rebuilt, deletekey.jsonandvault.jsonand re-enter the hosts.
Tests
Four regression scripts live in test/; only two need a real target, and none of them contains a credential.
npm test # the three host-free suites (this is what CI runs)
node test/diagnose.mjs # shell translation, octal decoding, failure attribution
node test/unit.mjs # ssh/scp argument construction, timeout/cancel output retention, wait/detach scripts
node test/background.mjs # background job registration contract
# needs a real target
$env:LIVE_KALI_HOST = '<host>'
$env:LIVE_KALI_USER = '<user>'
$env:LIVE_KALI_PASS = '<password>'
$env:LIVE_WIN_HOST = '<host>'
$env:LIVE_WIN_USER = '<user>'
$env:LIVE_WIN_KEY = '<private key path>'
node test/live.mjs
node test/selftest.mjs
The three host-free suites skip the cases that need to spawn a child process under a restricted sandbox
(EPERM) and report the skip; everything else still runs. Each script uses its own temporary directory and
never touches a real vault.
Current state: 89 host-free unit cases plus 25 live end-to-end cases against a Kali and a Windows 10 target.
Package layout
package.json bundle manifest (dsh.bundle.patch points at cordis.patch.yml)
cordis.patch.yml Loader row definition (id: ssh-vault)
index.js plugin entry and the four model-facing tools
lib/vault.js AES-256-GCM vault + DPAPI / scrypt key protection
lib/ssh.js OpenSSH driver: argument construction, askpass, attribution, wait/detach scripts
lib/background.js ctx.jobs background task adapter
scripts/set-secret.mjs offline password entry (neither DSH nor the model is involved)
locale/{zh,en}.json plugin manager title and description
icon.svg plugin card icon
README.md this file (English)
README.zh-CN.md 中文说明
CHANGELOG.md version history
CONTRIBUTING.md red lines, code style, testing requirements
SECURITY.md security boundaries and vulnerability reporting
LICENSE MIT
test/ regression; the first three need no host, the rest need a real target (not shipped)
dist/ build artifacts, tgz / zip (not shipped)
The published file list is the files allowlist in package.json. Repack after changing files:
$env:npm_config_cache = "$PWD\.npm-cache"
npm pack --pack-destination dist
Remove-Item .npm-cache -Recurse -Force
Contributing
Issues and pull requests are welcome. Read CONTRIBUTING.md first — this plugin stores other people's passwords and runs commands on their machines, so that document defines the red lines and the testing requirements.
npm test
Version history is in CHANGELOG.md; security boundaries and reporting are in SECURITY.md.