dsh-hardssh
September 17, 2026 · View on GitHub
English · 中文
SSH workspace + SSH operations plugin for DeepSeek Harness (DSH). Compatible with DSH 0.1.5 (tested against kernel 0.1.5-rc.1).
Turn any directory on a server into an SSH workspace: once bound, file I/O and command execution in that session run transparently on the remote host — you and the agent work exactly as if it were local — plus a complete SSH operations panel (terminal, transfers, tunnels, commands).
Screenshots
Turn a server directory into a workspace — file I/O and commands route transparently to the remote host
Manage SSH workspaces grouped by server: connection badges, edit/delete servers, add a new server
Why this plugin
1. Remote capability with zero changes to plugins
This is the key difference from other SSH approaches: the official core is not modified — only DSH's service seams are replaced.
cordis.patch.yml disables the deployment's built-in fs-sandbox / subprocess
rows and mounts this plugin's routing facades instead. As a result any plugin that
works through the standard ctx.fs / ctx.subprocess interfaces runs on the remote
host inside an SSH-workspace session, with no SSH code in that plugin and no
awareness of the remote side at all.
In short: your existing plugin ecosystem runs on the server out of the box.
glob / grep are part of that too, through the workspace-search bridge. The
official dsh-tool-fs-search plugin spawns the client's bundled ripgrep at an absolute
local path, which cannot read the remote workspace. Instead of refusing that spawn (the
pre-0.2.5 behaviour) the subprocess seam now serves it from the bound host: when the
host has ripgrep the identical argv runs on the server, and when it does not the
search ladder answers and the result is projected back into ripgrep's own output shape
(a --files listing / rg --json match records), so the native tool layer formats it
unchanged. Paths are POSIX paths under the workspace root, and a path argument outside
that root is refused. remote_search remains the tool for regex syntax and explicit
budgets, and for hosts where no regex engine is available. Likewise pwsh /
powershell / cmd are client-native binaries and run on this machine. Remote file
I/O and command execution (read / write / edit / bash) go through the replaced
seams and do take effect on the server.
2. One generic base, adaptable to every plugin
Workspace capability is not welded to SSH. Underneath sits a platform-neutral base:
WorkspaceRecord / WorkspaceProvider / WorkspaceConnection / capabilities
+ WorkspaceRegistry / WorkspaceLedger / WorkspaceRouter / switch facades
- SSH is just one provider (provider id
ssh);localis another. The same base can hostdocker/wsl/ cloud devboxes / remote containers — with no changes to the plugins, tools or UI above it. - A single runtime: no second assembly, no dual local/remote ledger, no runtime
mode switch. Seam routing, workspace CRUD, the
remote_*tools and the host-delete guard all read the same ledger. - Capability-based degradation: a provider implements only what it supports
(
workspace.fs/workspace.process/workspace.search); consumers that cannotget()a capability degrade gracefully.
3. A few interface swaps for the rare plugin — with an agent-ready manual
Most plugins need zero changes through the seams. The rare plugin that brings its own file/process abstraction or its own local/remote state only has to swap a few touch-points for their generic-base equivalents:
| What the plugin has today | Replace with |
|---|---|
direct node:fs / node:child_process | ctx.fs (DSH FileSystem) / ctx.subprocess (SubprocessRuntime) |
| its own path→remote map or "remote mode" flag | ctx.workspaceCore.findByAnchor() / openByAnchor() |
| its own workspace handle type | WorkspaceCore / WorkspaceConnection / WorkspaceRecord (@tiphareth/dsh-hardssh/workspace) |
| its own file/process capability contract | connection.get('workspace.fs' | 'workspace.process' | 'workspace.search') |
| its own client-side local/remote global switch | ctx.sessions.list (current + byId[id].cwd) + longest-anchor match over the workspace snapshot |
Point an agent at packages/dsh-hardssh/SKILLS.md and it can do the adaptation itself — that file is an execution manual for agents: detection commands, the interface mapping table, copy-ready snippets and a self-check list.
4. The session is the server: the console cannot hit the wrong machine
The right-sidebar SSH console takes its target from the current session's SSH workspace; there is no host dropdown:
- whichever SSH session you switch to is the server its terminal / transfers / tunnels / commands talk to;
- for a local-workspace session the console renders a blurred, disabled mask that explains why — instead of silently operating on the local machine;
- startup/refresh connects only the current session's server; historical sessions are never swept;
- a failed connection raises an explicit error dialog (cancelling a password/fingerprint prompt yourself is not a failure).
5. VSCode Remote-SSH style secure defaults
- Passwords / passphrases are never persisted by default (
secretStorage: none): entered once and reused for the lifetime of that connection (an idle pool recycle asks again); - opt-in
vaultmode (AES-256-GCM + scrypt) for unattended agents, stored at~/.dsh/ssh-secrets/dsh-ssh-vault.json. That directory is inside~/.dsh(which the fs seam declares a local root), so the protection is an explicit DENY —deniedRootsrefuses it onresolve/lstatand on every target-based read/write path, and the pre-relocation path is denied too — not its location. Plainly: a command running as the same user on this machine (e.g. the client-sidepwsh) can still read the file; what keeps the credential safe is that it is encrypted and that auto-unlock fromDSH_CREDENTIAL_PASSWORDis off by default (vaultAutoUnlock: envopts in), so a stolen file is an offline scrypt target rather than a usable credential; - host-key TOFU: fingerprint confirm on first connect, immediate warning on change;
- clicking Connect shows a connecting indicator on the workspace badge; a wrong password / rejected auth / dropped connection immediately re-opens the password dialog with the concrete SSH reason (not a generic failure);
- strict remote path confinement: the provider owns root confinement,
..is rejected in relative paths, symlink escapes fail closed; - optional per-host command guard: configure a set of "forbidden commands" for one host and the agent's attempt is refused with your own message (e.g. "this is a login node — submit with srun/sbatch"). Nothing is intercepted by default; see Command guard below.
Features
- SSH workspaces — any
user@hostdirectory can become a workspace; the bound session routes remotely. Sidebar rows carry the server badge (connected / disconnected, hover shows the remote directory). - SSH operations, bound to the session — web terminal (xterm + WebSocket PTY), SFTP upload/download, local port forwarding (reach internal databases/services), remote commands on the current server.
- Host management — the left-sidebar "SSH workspaces" panel lists every host and
workspace grouped per server with connected / disconnected badges, plus CRUD and
~/.ssh/configimport. - Agent tools —
ssh_list/ssh_exec/ssh_upload/ssh_download/ssh_tunnel/ssh_cluster, plus the remote workspace toolsremote_status/remote_ls/remote_search.glob/grepwork remotely too (see the workspace-search bridge above);remote_searchstays the tool for regex syntax, explicit budgets, and hosts without a usable regex engine. - Command guard (optional, per host) — give one host a set of forbidden commands
(regex lines and/or command names that are auto-unwrapped across
bash -c/sudo/ absolute paths); the agent'sssh_exec/ssh_cluster/bashattempt is refused with your own message. Editable right in the host dialog. Off by default. - Remote address display — the "workspace files" panel and the sidebar row tooltip show the real remote path (the local anchor directory is only a routing placeholder and is never exposed to the user).
- Multi-host — any number of hosts (
host/port/user+ key, password, orSSH_AUTH_SOCKagent); passwords are optional at creation. Cross-host fan-out viassh_cluster. - No core modification — shipped as a normal plugin (directory flow, left-sidebar global entry row, right-sidebar tab); the official workspace core is untouched.
Install
Published on npm. The stable release is 0.2.5 (installable as
add @tiphareth/dsh-hardssh); this checkout is 0.2.6-alpha, a pre-release, so
name the version explicitly:
# the pre-release in this checkout
dsh plugin --profile web add @tiphareth/dsh-hardssh@0.2.6-alpha
# or stay on the stable release
dsh plugin --profile web add @tiphareth/dsh-hardssh
# via npx when `dsh` is not on PATH
npx --yes @deepseek-ai/dsh plugin --profile web add @tiphareth/dsh-hardssh@0.2.6-alpha
For development / local iteration, install from the source checkout or a local tarball:
# source link (rebuild lib/ after edits and restart dsh web; no re-packing)
dsh plugin --profile web add link:</path/to/dsh-hardssh>/packages/dsh-hardssh
# or pack a tarball first
pnpm --filter @tiphareth/dsh-hardssh pack --pack-destination dist
dsh plugin --profile web add </path/to/dsh-hardssh>/dist/tiphareth-dsh-hardssh-0.2.6-alpha.tgz
Alternatively add the package to the profile's dependencies (file:... → tarball) and
to dsh.profile.bundles, then restart dsh web.
npm package page: https://www.npmjs.com/package/@tiphareth/dsh-hardssh
See "Why this plugin 1" for the seam mechanism: it compresses core-version adaptation into a thin layer, but not to zero — the plugin still depends statically on public DSH contracts, so the supported range is declared explicitly below.
Compatibility
| Plugin | Verified DSH | Node | Remote hosts |
|---|---|---|---|
0.2.5+ (currently 0.2.6-alpha) | >=0.1.5-rc.1 <0.1.6 (production verified on 0.1.5-rc.1; CI runs the same suite on Node 22.19/24) | ^22.19.0 || >=24.0.0 | POSIX (verified with a GNU userland: CentOS/RHEL; BSD/BusyBox hosts without the GNU flags fall back to SFTP — limited but usable) |
The earlier
0.1.5-alpha.1is not supported:dsh-client-ui-slots@0.1.5-alpha.1declares nomainslot, so the workspace panel has nowhere to mount (the matrix fails typecheck). Seecompat/README.md.
- Core contract: the runtime exports the plugin actually uses (
FileSystem/FsError/SubprocessRuntime/SandboxedFileSystem/defineTool, …) are listed insrc/runtime/compat-contract.tsand imported individually by a test;peerDependenciesno longer use an unbounded"*". - Optional integrations (
settings/systemPrompt/webServer/ client slots) degrade instead of failing: the plugin loads and only the corresponding surface is missing. - Visible state:
GET /api/dsh-ssh/healthreportsready/degraded/failedper surface (SSH tools, workspace runtime, file routing, command routing); the workspace panel shows a banner explaining any non-ready surface. - A failing seam cannot take down the host: if the workspace runtime fails to initialize, the replacement rows still mount the local backend (local I/O and commands keep working), the managed anchor window stays fail-closed, and the
ssh_*capability survives on its own. - Remote path canonicalization needs no GNU tools:
workspace.fspath resolution now uses the protocol-level SFTPrealpath(a missing leaf is resolved through its nearest existing ancestor with the suffix re-appended) instead of runningrealpath -mz … | base64 -w0, so a BSD/macOS or BusyBox host no longer breaks every path resolution just for lacking GNUrealpath -m/-z. - Remote search picks its rung from a probed capability report: once per connection generation the plugin asks the host what it can actually do (
rg,find -printf/-mmin,grep -Z/--exclude-dir,mktemp— never inferred fromuname). Content search runsrg→ POSIXgrep→ an SFTP walk, filename/glob search runs POSIXfind→ the same SFTP walk. The SFTP rung runs no remote command at all, carries depth/hit/byte budgets, and skips.git,node_modulesand symlinked directories.remote_searchacceptssyntax="fixed"(default) or"regex"; regex needs ripgrep or GNU grep on the host and fails loudly otherwise.
Quick start
- Add a host — left-sidebar "SSH workspaces" panel → new server: alias/host/port/user. The password may be left empty (adding never connects; it is asked on first use).
- Add an SSH workspace — sidebar "Add workspace" → SSH workspace → pick a server → browse the remote directory (the first browse connects automatically: confirm the host fingerprint if untrusted, then enter the password once) → name it.
- Work — in that workspace session, reading/writing files and running commands executes on the remote host; the left panel shows the remote directory and the connection badge.
- SSH operations — switch to that SSH-workspace session and open the right-sidebar tab strip "+" → SSH (or the guide entry). Terminal, transfers, tunnels and commands all act on the current session's server.
Configuration
| Key | Type | Default | Meaning |
|---|---|---|---|
announceToAgent | boolean | true | inject SSH system-prompt guidance & tools into the agent |
enabled | boolean | true | SSH-workspace surface switch, not a plugin master switch. It gates only what this plugin mounts: the /api/dsh-hardssh workspace CRUD routes, the remote_* workspace tools and the workspace guidance section. The SSH operations capability (host manager, ssh_* tools, /api/dsh-ssh, web terminal) is controlled separately by the enabled key of the dsh-ssh settings namespace; the shared engine/host store, the fs/subprocess routing seams and the connection pool keep running. |
secretStorage | enum | none | none = no persisted credentials, prompt at connect (VSCode Remote-SSH style); vault = encrypted storage (unattended agents). This plugin config is the only source: the vault and host store are constructed from it once at plugin load, so changing it requires a plugin reload / dsh web restart. |
vaultAutoUnlock | enum | off | Whether the vault may auto-unlock from the DSH_CREDENTIAL_PASSWORD environment variable at plugin load: off (default, master password entered by hand) or env (explicit opt-in for unattended setups). That variable is visible to anything running as the same user, hence off by default. Only meaningful with secretStorage: vault. |
Example (cordis.patch.yml):
- id: hardssh
name: dsh-hardssh
config:
secretStorage: none # or vault
Command guard (optional)
Why: some hosts must not run heavy work inline — the classic case is a Slurm cluster's login node, where you may only submit jobs. Configure a set of "forbidden commands" for that host and the agent's attempt is refused with your own message, steering it to the correct submission path.
Off by default: a host without a commandPolicy behaves exactly as before.
Where to configure it
Both write the same field into that host's entry in ~/.dsh/dsh-ssh.json:
- GUI — left sidebar "SSH workspaces" → the host row's ⚙ (edit server) → three inputs: "Forbidden commands (one regex per line)", "Forbidden command names (one per line, auto-unwrapped)", "Allowed command names (optional)" and "Message shown on block (optional)". Saving applies immediately (the form is the truth: clearing it removes the guard).
- Edit the config directly:
{
"alias": "login-node",
"host": "192.0.2.10",
"user": "alice",
"auth": { "kind": "password", "secretRef": "…" },
"commandPolicy": {
"deny": [
"(^|[;&|(])\\s*(?:/?[^ /]+/){0,3}(python[0-9.]*|ipython|Rscript|R|make|gcc|g\\+\\+)(\\s|$)"
],
"denyCommands": ["python", "python3", "Rscript", "R", "matlab", "julia", "make", "cmake", "gcc", "g++"],
"allowCommands": [],
"hint": "This is a Slurm login node; submit work with srun/sbatch instead."
}
}
Which rule kind to use
| Field | How it matches | Typical forms it catches |
|---|---|---|
deny (regex) | the whole command text (anchored at command positions) | python x.py, cd /a && python x.py, /usr/bin/python3 … |
denyCommands (names) | unwrapped first: leading FOO=bar skipped, wrapper words peeled (sudo / env / time / nohup / bash -c "…"), path prefix stripped to basename | bash -c 'python x.py', sudo -u me python3 …, nohup /usr/bin/python3 x.py |
allowCommands | exceptions to the names above (allow wins) | a name you do allow to run inline occasionally |
They can be combined. Submission/query commands (srun -p gpu python train.py,
sbatch run.sh, squeue) are not caught, because srun/sbatch are not in the
denied names and the regex is anchored at command positions.
Where the refusal happens
- Tool layer:
ssh_exec/ssh_cluster/bash— resolved to the host by alias or by the session's bound workspace; - Seam layer: every remote spawn through
ctx.subprocess(including third-party plugins spawning directly) checksargv[0]and the joined command line.
The refusal text looks like:
dsh-hardssh: 已阻止在 login-node 上执行该命令(命中该主机的禁止规则 /…/ 或 禁止命令 "python")。
This is a Slurm login node; submit work with srun/sbatch instead.
Honest boundary
This is a guardrail, not a sandbox: $(…), base64, or a script that calls the
compute command later all evade it. Its value is preventing accidents and pointing the
agent at the submission path; hard enforcement belongs on the server (Slurm partition
limits, pam_slurm_adopt, a PATH shim). Also, bash itself should not go into
denyCommands: it is treated as a wrapper (which is exactly what makes
bash -c python catchable), so listing it would also block every spawn of the bash
tool itself.
Data locations
- Host config:
~/.dsh/dsh-ssh.json(including each host'scommandPolicyguard, see above) - Generic workspace ledger:
~/.dsh/workspaces/index.v1.json - Workspace anchors:
~/.dsh/workspaces/anchors - Host-key trust:
~/.dsh/ssh-known-hosts.json vaultmode ciphertext:~/.dsh/ssh-secrets/dsh-ssh-vault.json(inside~/.dsh, but denied to the fs seam on every dispatch path)
These files are written with owner-only permissions (0600 / 0700).
Development
pnpm install
pnpm --filter @tiphareth/dsh-hardssh typecheck # type check
pnpm test # test suite (~12s; vault cases moved out)
pnpm test:vault # vault crypto cases only (~21s, scrypt is slow by design)
pnpm --filter @tiphareth/dsh-hardssh build # build (lib/ artifacts)
Pack & deploy: pnpm --filter @tiphareth/dsh-hardssh pack --pack-destination dist,
then pnpm add file:... into the profile and restart dsh web.
FAQ
Prompted to enter a password / "credential required" — passwords are never saved by default: connect and remote browse ask once per session; a process restart asks again.
Host key changed / possible MITM — the server was reinstalled or rotated keys: opening an SSH-workspace session on that server raises the "key changed" dialog; press "Reset" and re-trust.
Added a host but browsing fails — make sure the host config is correct; the first browse completes "trust fingerprint + enter password" first.
Does opening the GUI connect every server? — No. Startup/refresh connects only the current session's server; historical sessions are never swept.
A connection fails with no visible feedback — a "Unable to connect to {alias}" dialog names the concrete reason (unreachable host, auth failure, host-key problem, …). Cancelling a password or fingerprint prompt yourself is not a failure.
The right-sidebar SSH console is greyed out / won't open — right-sidebar tabs are per-session: switch to a session first, then open the tab from the strip "+". If the session uses a local workspace the console stays blurred and disabled — it is only available for SSH-workspace sessions.
Why can't I pick a server in the console? — By design: the console follows the current session's server so a panel action can never hit the wrong host. Switch sessions to change server.
Where is my password stored? — nowhere by default (session memory only, reused
for the lifetime of the pooled connection); with secretStorage: vault it is
encrypted in ~/.dsh/ssh-secrets/dsh-ssh-vault.json. The fs seam refuses that
directory (and the pre-relocation path) wherever it is addressed, but a local command
running as the same user can still read the file — the real protection is the
encryption plus auto-unlock being off unless vaultAutoUnlock: env is set.
How do I stop the agent from running compute commands on one server (e.g. a login node)?
— give that host a commandPolicy: click the host row's ⚙ (edit server) in the GUI and
fill in the forbidden command names plus your message; or edit ~/.dsh/dsh-ssh.json
directly. Nothing is intercepted by default. See
Command guard — and note it is a guardrail, not a sandbox.
How do I make another plugin work with SSH workspaces? — most plugins need zero changes (they go through the seams). For the rare one that needs interface swaps, point an agent at packages/dsh-hardssh/SKILLS.md.
Safety
With host credentials configured, the agent runs commands on those hosts as your user.
Only add machines you trust. By default nothing is written to disk; host keys use
first-trust TOFU. Enable vault mode explicitly (and guard the master password) only
when unattended access to password hosts is required.
The full trust boundaries, capability inventory, and a per-finding answer to static scanners live in SECURITY.md.
License
BSD-3-Clause
Changelog
See CHANGELOG.md.