A plugin pack for the DeepSeek Harness web profile: one human, one machine, one browser session. Loopback only, nothing that weakens the harness's authentication, zero npm dependencies, and no outbound request of its own except the pinned ffmpeg download below.
- Loopback only. The app binds
127.0.0.1; the CLI refuses an all-interfaces bind. Never put it behind a proxy, tunnel, port forward or published container port — that turns an authenticated surface into network-exposed code execution. - The printed URL is a password (it carries the launch token). Never paste it into a chat, an issue, a screenshot or a recording.
- No secrets in this repository, enforced not promised: credentials live in
$DSH_HOME/.credentials.yaml, outside this tree, andscripts/checks/check-no-secrets.mjsfails on a credential-shaped file before it can be committed. - Nothing patches core: every plugin is a standard dsh bundle, and every route it adds sits behind the harness's own session check.
- Zero npm dependencies, vendored engines pinned by hash, no telemetry, no
auto-update, no HTTPS server. Writes go only under
$DSH_HOME, the session workspaces you open, and the Desktop for the two features that save a picture there. - Personal tooling, one trust domain — not multi-tenant. Security fixes land on
mainas alpha bumps; there are no maintenance branches.
Supported: main, and the harness line pinned in .dsh-version.json
(0.2.0-rc.2); any other published line is not tested and not claimed.
dsh-rightbar, dsh-rightbar-files and dsh-open-in-app are forks of that line's
client bundles, so a line bump is deliberate: bump the pin, run
scripts/tools/sync-vendored.ps1, re-verify. The pack targets the web profile only,
stays alpha until promoted, and its installer is idempotent — uninstall removes
only the bundle, its patch layer and the skill folders it wrote (each marked
.vncode-<package>).
Report privately: on GitHub, Security → Report a vulnerability in
vecnode/vncode. Include the package and
version, the harness line you tested, the impact, and reproduction steps if they are
safe to share. Best-effort acknowledgement within 3 business days; credit on
request.
Good-faith research on your own installation is welcome — no attacking other
people's instances, no data that is not yours, no destructive or denial-of-service
tests, no social engineering. Out of scope here (report upstream): bugs in
@deepseek-ai/dsh-* itself, model behaviour, the LLM provider, the harness's own
sandbox and file policy, and anything needing root/administrator, physical access, a
compromised OS or browser, or an already-unlocked session.
The asset is your machine behind one browser session: the files the model's
tools can reach, the workspace, conversation history, model API credentials, and the
harness's own shell surfaces. The boundary is plain HTTP over loopback,
authenticated by a session cookie minted from a per-process launch token —
everything the pack adds is mounted through the harness's own connection service
and inherits that gate instead of inventing one. Every surface follows one shape: an
exact-path route behind that fence, read-only where possible, argv-only spawns with
a deadline, realpath containment, and writes that are create-exclusive under
host-generated names (the package READMEs list the per-surface guards).
| Adversary | Defence |
|---|---|
A page in the same browser calling 127.0.0.1 |
HttpOnly; SameSite=Strict cookies, a Host/Origin fence that refuses Sec-Fetch-Site: cross-site, no CORS headers |
| DNS rebinding, or a stranger who finds the port | the Host header must be a loopback (or explicitly trusted) authority and the cookie's signed audience is bound to it; the index and every /api route answer 401 |
| Hostile model output or plugin input reaching a route | session roots resolved host-side, realpath containment, argv-only spawns, pattern-checked ids, create-exclusive writes |
| A leaked launch URL | it works only while that process runs and only from a loopback-reachable client; a leaked cookie is revoked by rotating the signing secret |
Not defended against: root/administrator, a compromised OS/browser/extension, a person with your unlocked session, and — the important one — deliberate exposure. Bind a LAN interface, forward the port or add a tunnel and the cookie is all that stands between that network and your machine.
Five layers, all the pinned line's own code; the pack only registers routes behind
them. The bind is 127.0.0.1 (the CLI refuses --host 0.0.0.0). The launch
token is 32 random bytes per process, printed as the only authentication input;
GET /?token=… exchanges it (timing-safe) for a cookie and answers 303. The
session cookie is dsh-auth-<hash(authority)> valued
v1.<payload>.<HMAC-SHA256>, Path=/; HttpOnly; SameSite=Strict, 30-day Max-Age
by default (cookieMaxAgeDays on the connection row). The Host/Origin fence
answers 403 for a foreign authority or a cross-site fetch and 401 for failed
authentication. The index gate serves index.html only to a session; the JS/CSS
bundles are public and contain no secrets. The signing secret lives in
$DSH_HOME/.credentials.yaml, written 0600, and the harness refuses to boot if
that file is group/other-readable on POSIX.
One call, through those gates: the printed URL buys the cookie, the cookie and the Host/Origin fence are checked on every request, and only then does a pack route run.
app/ owns a root — $VNCODE_HOME (%LOCALAPPDATA%\vncode by default) — and hands
the harness a home inside it, <root>/home, as DSH_HOME. It does not use ~/.dsh
as a default: that folder belongs to whichever harness used it first, and a home the app
did not create is a home it cannot promise. A candidate is used only after a real write
probe, the first rung of the ladder is a -DshHome the person typed (a refusal there
ends the run rather than quietly using another path), and the app never writes a
note, a log or a record into a home it refused — the one write it makes outside its own
root is the probe file, created and deleted at the root of a home it is considering, because
"a child can write here" is not knowable any other way.
Nothing outside this app's own root is ever the home it runs against. The harness's own
default, ~/.dsh, is not a candidate: a folder that carries everything it needs must not reach
into a person's profile to START. It survives as the SOURCE of a one-way copy — read, never
written, never run against — offered while it can be read and there is something left to bring,
and the copy's own credentials file is a byte copy behind a second click that this process never
parses. Start installs the pack into the home in use, and the key row asks for the key, which is
what that rung used to exist for.
Four hardening rules travel with those paths, and each is a cargo test case:
- A path is cleaned before it is used (
userpath.rs):~expanded, relative resolved against a captured cwd,./..folded lexically, a NUL or an empty value refused by name, and the exported path is the REAL one with the Windows\\?\form stripped. A path that means two things to two processes is how a write lands somewhere nobody chose. - Only a directory this app CREATED is hardened to
0700on Unix — the home holds.credentials.yaml, the root holds an npm cache that can hold registry credentials. A home the app merely JOINED is never chmodded: it has an owner and a history, and the probe reportscreatedprecisely so those two cases cannot be confused. On Windows nothing is chmodded: the directories live under the user's profile and inherit its ACL, and a half-written ACL is worse than an inherited one. - Nothing in the preferences can name a program.
<root>/prefs.jsonstores a browser id the table already knows and a home path, both validated on read; a record this build cannot vouch for (wrong version, unknown id, empty path) is DISCARDED rather than repaired. This matters because the app runs a program for you: a stored path would be an execution primitive. - The browser launch is checked at the last moment.
browser::commandrefuses any URL that is not loopback — the ready line was already checked, and a rule that holds at one end of a pipe is not a rule — starts a PROGRAM with the URL as exactly one argv element, and never a shell. On Windows the platform opener isrundll32.exe url.dll,FileProtocolHandler, which is a program rather than a shell;explorer.exewas dropped because it silently did nothing from a child. The Chrome-first table's last rung is the platform's own opener, so "a browser this build has never heard of" is still the OS's business rather than a refusal.Open tabis the one control that hands the token to a browser, on demand and in memory: the panel's address row draws the URL with its query stripped, and the log redactstoken=on the way in.
Adopt is the one thing that reads such a home: it copies skills/, sessions/,
storages/ and attachments/ into the home the app proved it can write, never the
caches (dsh-pdf 512 MiB, dsh-diagrams 200 MiB, dsh-media up to 8 GiB — all
reproducible), never the composed profiles/, never over a file that already exists,
and never through a symlink it would have to follow. The credentials file is a
separate click and a byte-for-byte copy: its value is never parsed, never held
in a KeyState, never drawn — the presence-only rule above is exactly as it was, and the
offer is decided by the file's SIZE (has the source's got content, is the target's
absent or empty) rather than by reading it. Use it takes the other approach: it points
the app at the existing home rather than copying it, which hands that home's keys to a
process this app starts — the same trust the scripts\run-web.bat path already extends.
A key never has to come from the machine at all. The panel's key field asks for it
instead: a fresh portable home (everything inside <repo>\.vncode) starts with no
credentials document and no key, so the panel opens the field once, the person pastes, and
keystate::write_env_key puts DEEPSEEK_API_KEY=<value> into <home>/.env. That is a
layer the harness's own resolution reads, it needs no machine credentials, and it keeps
the secret inside the folder — which is the point of a portable installation. Nothing is
read on the way: the field is one-way, and the only thing the panel learns afterwards is
which layer a fresh keystate::read found a key in.
Every child's environment is composed rather than inherited
(vn-shell-runtime's env.rs): DSH_HOME is set, the app's own VNCODE_HOME and the
launcher's console markers are removed — an environment is inherited unless a variable
is removed, a leak that was measured while this was written and is now read back out of
the child by check-shell.mjs — and TMP/TEMP/TMPDIR point at <app root>/tmp when
the machine's own temp refuses a child, which is the case a real log measured (the
harness's spill store died on mkdtemp).
cargo build --release -p vn-shell --features pane compiles one optional dependency —
wry, which drives the engine the operating system already has (WebView2, WKWebView,
WebKitGTK) — and shows the harness inside this window instead of in a browser tab. The default
build compiles none of it, so none of this is in the shipped default yet. Six rules travel with
it, and the first is what the rest rest on:
-
The launch token never leaves the process. There is no argv and no second program: the token goes to an engine in this very process, which is strictly less exposure than the hand-off's "one argv element of a program this app found". It is still never drawn, never logged and never written — the pane's bar shows
Status::public_url, whose query is stripped by construction. -
Only the harness's own loopback address is ever loaded, checked at the last function before a navigation and in the navigation handler — and the handler refuses a navigation rather than following it, because a page that can move this window to another origin is a page that can take the token with it.
-
No window is ever opened from inside the pane, no download is ever accepted, and permissions are answered by the app rather than by the engine: the clipboard is granted, because a chat interface's copy buttons and its composer need it, and camera, microphone, screen capture, geolocation, notifications, MIDI and sensors are denied by name rather than left to the engine's own default flow. (
with_permission_handleris why this workspace pinswryat 0.57: measured, 0.55's macOS delegate GRANTS media capture itself, and its Windows backend allows clipboard reads and lets every other kind through to the engine.) -
No channel into the app. There is no IPC handler and no injected script, so a page the harness serves has no way to reach this process. The pane is a viewer, and the only direction that exists is the one URL above.
-
Its own profile, inside the app's root —
<app root>/webview, and never the directory the Chrome rung uses, because an engine profile and a browser profile in one directory is a way to lose both. The engine is destroyed on Stop rather than hidden: a hidden page is still a live page, and a live page holding a token that has stopped meaning anything is a credential nobody is watching. -
The rectangle is the app's to keep clear. A child web view paints above the surface egui draws on and cannot be clipped or drawn over, so it is shown only while its own page is in front, the harness is ready, nothing overlays it and the rectangle has an extent — and the panel draws its rows instead of it, never under it. That rule is
vn_shell_core::pane, and it iscargo testcases rather than something anybody checks by looking at a window. -
What the engine records about WHERE the pane went is taken away again. A web engine keeps a browsing record, and this one is no exception: MEASURED, after a single load of the harness the full URL — token and all — was sitting in
History,FaviconsandTop Sitesinside the pane's own profile under<app root>/webview. No care in this app changes what an engine does with a URL it is given, so the app removes those files: at startup, before any view exists (the pass that cannot fail, because a delete while the engine is alive is a sharing violation), and again best-effort when a view is given up. Anything left that still carries the token is named in the pane's own bar rather than hidden, so an engine version that invents another record file is visible instead of silent — which is also why rule 1 above says the token never leaves the process and not that it never touches a disk the engine owns.
- Keep it on loopback: the pack's launcher or
npx @deepseek-ai/dsh@0.2.0-rc.2 web, no--host, no--trusted-host. Loopback traffic does not cross a firewall, so no firewall rule "fixes" an exposed bind. - Treat the printed URL as a password, and stop the app when you are not using it.
- Know what logs out: stopping the app kills the launch token; deleting the
site's cookies ends that browser's session; a different port invalidates
cookies (name and audience derive from
host:port); a plain restart does not log anyone out — the signing secret is durable. - Rotate the secret to revoke every cookie at once: stop the app, delete
only the
client-connection/browser-sessionentry from$DSH_HOME/.credentials.yaml(it also holds your model keys), save, restart. To shorten sessions instead, setcookieMaxAgeDayson theconnectionrow in$DSH_HOME/profiles/web/cordis.patch.yml— a patch replaces the row's wholeconfig, so restate what you rely on. - Harden the machine — loopback is only as private as the box: run as your own user (never root/administrator), patch the OS and browser, use disk encryption, lock the screen. "Locked" does not mean hidden from a local process or user, does not protect an unlocked screen, and does not sandbox the model's tools (that is the harness's own file policy).
# 1. Loopback and nothing else. Expect 127.0.0.1, never 0.0.0.0.
ss -ltnp | grep 3080 # Linux
lsof -nP -iTCP:3080 -sTCP:LISTEN # macOS
# 2. Unauthenticated access is refused. Expect 401 and a one-line body.
curl -i http://127.0.0.1:3080/api/dsh-cmdbar/activity
curl -i http://127.0.0.1:3080/ # the index answers 401 too
# 3. The fence works. Expect 403 for a foreign authority and for a cross-site claim.
curl -i -H 'Host: evil.example' http://127.0.0.1:3080/api/dsh-cmdbar/activity
curl -i -H 'Sec-Fetch-Site: cross-site' http://127.0.0.1:3080/api/dsh-cmdbar/activityWindows: Get-NetTCPConnection -LocalPort 3080 -State Listen. Then in DevTools →
Application → Cookies the dsh-auth-… entry must show HttpOnly,
SameSite=Strict, Path=/ and an expiry about 30 days out. A 401 on step 2 and a
403 on step 3 are the whole model working; the parts that are the pack's own are
pinned by node scripts/checks/check-node-routes.mjs.
dsh web prints one line once it is listening:
dsh web: http://127.0.0.1:3080/?token=<launch token>. The token is exchanged for
the session cookie, and everything after rides the cookie. The pack's launchers
(scripts\run-web.bat → scripts/run-web.ps1, scripts/run-web.sh) read it in
memory and never write it to a file (the POSIX half streams through an anonymous
FIFO for exactly that reason), never echo it, hand it to the browser as one argv
element (never a command string, so nothing in it reads as a shell metacharacter),
and open the URL only when it names a loopback address (127.0.0.1, ::1,
localhost; on Windows only a literal 127.x.x.x, so 127.evil.com is refused).
--no-open is passed so the hand-off happens once. The token dies with the process
(restart to kill a leaked URL); the cookie is signed with a durable secret, so a
restart does not log a browser out — rotate the secret for that.
- Nothing to trust at install time: no shipped package declares a runtime dependency, and the bundles are installed as live links.
- Vendored engines are pinned and hashed: CodeMirror 6, Mermaid and pdf.js are
built from their
vendor/folders and served by the packages themselves — no CDN, no runtime download. Each records a sha256 per file and a digest inlib/vendor/…/VERSION.json, re-hashed offline bybuild.mjs --check; forked bundles carry a GENERATED banner and are checked byscripts/tools/sync-vendored.ps1 -Check. - One binary may be downloaded, and only when the machine has none: ffmpeg. The
repository ships a pin (
packages/dsh-media/lib/binaries.json: official URL plus SHA-256 per platform-arch), never a 130–170 MB build.PATHwins; the archive is hashed as it arrives and compared with the pin before anything is executed; a mismatch installs nothing; unpacking uses the host's owntarand a.partialfile is renamed into place;DSH_MEDIA_NO_INSTALL=1forbids the download entirely.darwin-arm64is deliberately unpinned (no Apple-Silicon build publishes both a URL and a checksum; a pin that moves is worse than an honest gap). - Security-relevant behaviour is pinned by tracked checks: workspace containment, create-only writes, git option-injection refusal, screenshot refusals, diagram budgets, and the ffmpeg provisioning pipeline against a synthetic archive.
A secret in a commit is public for as long as the repository exists — in every fork,
clone and cache. scripts/checks/check-no-secrets.mjs, run by hand before anything
ships, scans exactly what git add -A would stage and fails on a key-shaped string,
a credential assignment (quoted name or not), a launch token in a URL, a
Google/Firebase api key, a GitHub/AWS/Slack/Stripe key, a private-key block or a
bearer JWT, plus a missing .gitignore credential rule. It never prints what it
found (file, line, rule and a masked preview only), its allowlist is empty by
design, and it self-tests that it fires.
A vendored bundle carries its author's credentials. A vendored Excalidraw this
pack shipped once carried its OSS Firebase config — Google api key
included, and Firebase web api keys are public by design — so vendoring it put a
google_api_key in this public repository and GitHub's scanning opened a
public leak alert naming Excalidraw's key, which this repository can neither
rotate nor revoke. The fix is a build patch, never an allowlist entry: a patch
under the engine's own vendor/<name>/patches/ blanks the apiKey value in every
file read from the pinned package's dist/, matched by key name so a rotated key
is redacted the same. Read what a third-party build ships before
committing it. If a credential is ever committed: rotate it first (assume it is
public), then remove it from history (git filter-repo or the BFG) and force-push —
deleting the file in a new commit does not remove it. Enable GitHub's secret
scanning and push protection as a second net.
- No API keys are read:
$DSH_HOME/.credentials.yamlis touched for presence only — a boolean, never a value. The app's key field is the same rule from the other side: a value the person TYPES into the panel is written to<home>/.envasDEEPSEEK_API_KEY, which the harness's own credential resolution reads as a documented fallback layer (@deepseek-ai/dsh-credentials-local: the managed store first, then the invoking project's.env, then the home's). The managed document is never written by this app — it is a strictCredentialRef-to-string YAML the harness refuses to boot on when it is malformed or readable beyond its owner. What the field guarantees, and whatkeystate::write_env_keyis: the value is masked in the window, held in memory only while the field is open, never parsed back, never held in aKeyState, never drawn, never logged (the log line and the note name the FILE), written atomically with0600on Unix, and every other line of an existing.envis preserved. A value that cannot be carried unambiguously (a newline, a quote, a backslash) is refused by name, and the sentence names the reason and never the value. - No way to weaken the harness's authentication, and no flag, setting or variable that turns the session check off.
- No WebSocket upgrade and no shell of any kind — the dock is a read-only transcript, and shells come from the harness's own terminal tabs.