dsh-permission-gate
A UI-level access gate and process-wide agent tool brake for
DeepSeek Harness (dsh).
Log in once → every open DSH page unlocks. Nobody logged in → the agent itself can't run a single tool.
⚠️ Read this first: this is NOT authentication
DSH's /api surface has no authentication of its own — that is a deliberate
property of the harness, not a bug this plugin fixes. The harness's own source
describes its request fence like this:
trustedHostsis a DNS-rebinding fence, explicitly not authentication
Any client that can reach the DSH port can bypass this plugin entirely by calling the HTTP API directly. This plugin gates:
- ✅ the browser page (a full-screen lock page injected into every page load)
- ✅ the agent's tool calls (a process-wide
tools.guardbrake)
It does not gate the HTTP surface. It is not a reverse proxy, not a session layer, and not an authorization system.
Always pair it with a real boundary:
| Real boundary | What it gives you |
|---|---|
Bind DSH to loopback (dsh web, the default) |
Nothing on the network can reach it at all |
| A VPN / overlay (WireGuard, Tailscale, ZeroTier) | Only your own devices can route to the port |
| A TLS reverse proxy that performs real authentication | The only place a real auth layer can live |
If you deploy this on a public port and believe you are protected, you are worse off than having nothing, because you will stop looking for the real problem.
What it does
- Full-screen lock page. Injected into every DSH page load via the
documented
webserver/index-injectchannel — no client bundle, no module loader, nothing to keep in sync with the SPA. - Global shared session. One session per process, one idle clock. Log in on your phone → your desktop page unlocks on its next poll. The clock expires → every page falls back to the login page.
- Strict 60-minute idle lock. No grace period, not even for a running task. This is intentional: the gate is a dead-man switch.
- Process-wide tool brake. While nobody is logged in,
ctx.tools.guarddenies every agent tool call. The agent cannot read files, run commands, open the network, or spawn subagents. - Real credentials. scrypt + random salt, stored at
~/.dsh/permission-gate.json(mode0600). The session lives in memory only, so a restart always requires a fresh login — but your password is never reset. - Server-rendered escape hatch.
/gateis plain HTML served by the host half. It works even if the injected script fails or is cached out. - Native settings module. A
权限闸门page inside DSH's own Settings, where the gate can be disabled — and disabling requires the password. - Collapsible status chip. A countdown chip in the corner; collapse it to a
slim tab on the right edge and click the tab to bring it back. The preference
persists in
localStorage.
Install
This package declares dsh.bundle, so it is a profile layer: installing it is one
command, and DSH reconciles the composition for you. There is no patch file to edit.
dsh plugin --profile web add dsh-permission-gate
# restart DSH once — the client roster is scanned at startup
The bundle inserts this row on its own (see cordis.patch.yml):
- insert:
- id: permission-gate
name: dsh-permission-gate
Migrating from a manual install? Delete that same row from your own
~/.dsh/profiles/web/cordis.patch.ymlfirst. The bundle now inserts it, and two entries sharing oneidwill collide.
On the next page load you get the lock page. The first visit asks you to set a password; every visit after that asks you to enter it.
Endpoints
All of them are served by the host half and sit behind the same origin as DSH.
| Method | Path | Purpose |
|---|---|---|
GET |
/gate |
Server-rendered login page. Always available. |
GET |
/gate/ui.js |
The injected browser script (cache-control: no-store). |
GET |
/gate/status |
{ state, expires, remainingMs, idleMs, hasCredential, authenticated } |
POST |
/gate/login |
{ password, mode } — mode: "setup" on first run. Sets the session cookie. |
POST |
/gate/touch |
"A human is present" — extends the idle clock. |
POST |
/gate/lock |
Drop the session and clear the cookie. |
POST |
/gate/mode |
{ password, enable } — disable/enable the gate. Requires the password. |
state is one of setup · locked · unlocked · disabled.
Threat model
| Scenario | Covered? |
|---|---|
| Someone picks up your unlocked phone/laptop and opens the browser | ✅ full-screen lock page |
| You walk away and an autonomous agent keeps working | ✅ strict idle lock + tool brake |
| A page is left open on a second device | ✅ shared session logic; all pages mirror the server state |
Someone in your tailnet calls /api directly |
❌ not covered — that is what the real boundary is for |
Someone can edit cordis.patch.yml or the package directory |
❌ not covered — a plugin cannot defend the composition that loads it |
| Someone restarts the DSH process | ❌ not covered — the gate lives in the process; a restart also clears the session |
Deliberate exemption
Three tools stay callable while the gate is locked, because they touch no files, commands, session content, or credentials:
get_goal · update_goal · todo_write
They exist so a locked agent can still report state and close out its own
bookkeeping. Remove the BOOKKEEPING_TOOLS line in lib/index.js if you want
a zero-exemption gate.
Recovery
| Situation | What to do |
|---|---|
| Forgot the password | Delete ~/.dsh/permission-gate.json → next visit asks you to set a new one |
| The injected UI is broken / cached | Visit /gate directly — it is server-rendered |
| Locked out completely | Set disabled: true on the row in cordis.patch.yml, or remove the row. cordis.patch.yml is watched live, so no restart is usually needed |
| Want to inspect state | curl http://127.0.0.1:3080/gate/status |
Design notes
Built while running DSH remotely from a phone. Three bugs are baked into the comments and the test suite, because each one is a trap anyone building injected UI for DSH will hit:
- A silent render crash is worse than a visible error. The lock page used
to throw on every render in the common path (
appendChild(null)when the "confirm password" field is absent) — the host-side guard kept working while the UI showed nothing, i.e. "no permissions, but you can still chat." Fix: never appendnull, and wrap the whole render in a fallback that puts the error on screen. - Never re-render a form on a timer. A 5-second status poll rebuilt the login form and wiped whatever the password field contained. Fix: re-render only when the state actually changes, and carry typed values across renders.
- A global session must not require a per-browser cookie.
/gate/touchoriginally demanded a cookie matching the session token. After a restart only the browser that logged in held a valid cookie, so every other page's activity was rejected with 401 — the countdown only ever went down. Fix:touchgrants nothing; it only requires that the shared session is unlocked.
Development
The smoke test runs the real lib/ui.js against a minimal DOM stub whose
appendChild throws on null, exactly like a real DOM. No browser needed.
npm test
Compatibility
Developed against DSH 0.1.1-rc.2 / cordis 4.x, and depends on these
harness capabilities:
- host:
webServer.register·webserver/index-inject·tools.guard - client: the
settings.sectionslot ·slotsandtimerservices
These are internal-ish APIs; a DSH upgrade may require a matching update here.
License
MIT
No comments yet. Be the first to write one.