dsh-perm-gate
- English README
- 中文 README
- 日本語 README
- 한국어 README
- Installation guide
- 中文安装指南
- 日本語インストールガイド
- 한국어 설치 안내
- Changelog
- 日本語 changelog
- 한국어 changelog
Compatibility note: v2.0.0 ships
ja/kodictionaries, but official DSH exposes onlyzh/enthroughLocaleRuntime(LOCALE_IDS = ["zh", "en"]). On stock DSH, selectingja/kofails withlocale "<id>" is not registered. Use a DSH fork that updatesLOCALE_IDS(locale-settings.ts) andLOCALESlabels (client/index.ts), then rebuild.
▼ DSH version compatibility
Two DSH lines are served from two long-lived branches, each with its own version series,
engines.dsh, and npm dist-tag (release layout):
DSH version Branch Version npm tag 0.1.0-rc.7 ~ 0.1.1-rc.x legacy1.x@legacy0.1.2-alpha.1 ~ 0.1.5-rc.0 main2.x@latest/@dsh-0.1.2(@2.xis a range)0.1.5-rc.1+ sync/0.1.5-from-main(=compat/0.1.5)3.xGitHub ref install (no dist-tag yet) The series number tracks the DSH line (
1.x= DSH ≤ 0.1.1,2.x= DSH 0.1.2+), and the majors fence each other: a^1.xinstall never resolves a2.xrelease and vice versa.engines.dshstates the same split but DSH never reads it — the ranges and dist-tags are what hold an old DSH on1.x.
@deepseek-ai/dsh-client-runtimewas removed at0.1.2-alpha.1— it did not merely move. Thelegacyline still reachesctx.slotsthrough it;maingets the same declaration from@deepseek-ai/dsh-client-ui-renderer/client. Two version-sensitive seams are handled by capability probes rather than version checks: (1) settings registration usesregister, which exists on both lines (installSectionis an addition, not a replacement); (2)effectivePolicyis a private method of the user-approval service on both, so it is read behind atypeofprobe and degrades to “policy unknown” when absent or throwing.
Version 2.6.0 — see the Changelog.
A single, self-sufficient, deterministic-first, fail-closed permission gate for DeepSeek Harness.
dsh-perm-gate decides every tool call through a fixed priority chain:
| Stage | Decision | What it is |
|---|---|---|
| P0 | deny |
deterministic hard-deny: credential material, protected-path mutation, dangerous shell |
| P1 | allow |
a precise, bounded session grant |
| P2 | deny/allow/ask |
static rule chain: blacklist first, then allow, then ask |
| P3 | allow/deny/ask |
optional LLM semantic classifier (default off) |
| P4 | ask |
official approval seam |
Strictly fail-closed: a P0 decision is never overridden by a grant, a rule, the classifier, or a human.
Why
The DSH safety ecosystem splits this across several plugins (dsh-permission-rules,
dsh-auto-mode, dsh-auto-review, dsh-movein-permissions). dsh-perm-gate merges the
gate + approval + (optional) classifier into one package, with one audit trail and no
cross-plugin version coupling.
Features
- Command whitelist / blacklist — matched on an argv decomposition (not a raw string),
with recursive descent into
sh -c/bash -c, pipeline detection, redirect-target checking, and recursive/force (rm -rf) recognition. - Deny priority — a matching deny rule wins over any allow rule.
- Session grants — precise
(tool, canonical fingerprint)grants withTTL+maxUses; re-running with a different target never reuses authority. Sub-agents inherit but cannot mint. - Pure-function rule engine — glob/regex compilation with a ReDoS bound, loud fail on malformed rules, and source-hash compile caching.
- Audit — every decision is logged as an
{ignorable:true}event with itscallId; the model-visible reason matches the recorded outcome. - 自动审查 tier (
permissive, pluspermissive-full) — an independent approval mode (separate from read-only, full-access and whitelist tiers) that is neither "auto-approve" nor blanket trust. Front-end exposes a single switch (permissive); the four backend strategies are combinable and driven by plugin settings — still fail-closed against P0. The permission picker and the settings row both show it under the product label 自动审查, with no icon. Thepermissive-fullvariant keeps the identical approval behaviour but drops the built-in file sandbox, which otherwise denies the named pipesgit clone/ Cygwin / ConPTY need. - Sandbox-escalation auto-answer (
trustEscalation) — a sandbox escalation is asked from inside the shell / pwsh / edit tool body, aftertools/pre-execute, so the gate never saw it and a call it auto-allowed still prompted you to approve the widening. With this strategy on, the exact call the gate cleared (matched bycallId) is answered here instead. - Risk-graded
llmAssist— a custom OpenAI-compatible LLM grades eachaskassafe/risky:<category>; hard categories (deletion, credential, remote, system, bulk) always ask, neutral enters verdict learning, and every failure stays fail-closed. - Verdict learning — neutral-risk asks that the human approves and that actually execute count up; after the threshold, the exact same operation (fingerprint-matched) auto-allows.
- Decision event feed — every decision is appended to a JSONL feed and surfaced by the browser half as a notice strip above the conversation input plus an approval-records tab (newest first) in the conversation view.
A preset deny-keyword blacklist (inherited from dsh-approval-gate's DEFAULT_DENY_KEYWORDS:
rm -rf, push --force, drop table, mkfs, git reset --hard, docker system prune, …) vetoes
any call whose text contains a keyword — case-insensitive substring, applied before whitelist,
grants and LLM. It is editable as a list in the settings card (preset entries are tagged, and a
one-click restore brings the preset back); unset or empty applies the preset — the blacklist
never silently turns off.
Install
Requires an existing DeepSeek Harness installation.
dsh plugin --profile web add dsh-perm-gate
Full install, upgrade, migration and troubleshooting steps live in the Installation guide — also available in 中文 / 日本語 / 한국어.
Configuration
Add the plugin to cordis.yml:
- id: dsh-perm-gate
name: dsh-perm-gate
config:
rulesFile: ./permissions.yaml # optional; defaults to $DSH_HOME/perm-gate/rules.yml
dshHome: $DSH_HOME # root pinned for protected-target checks
defaultAction: ask # allow | ask | deny
gatePresets: [permissive, permissive-full] # tiers where the gate is active (default)
sessionSweep: true # hourly cleanup of archived/dead sessions' gate data
Session sweep
On startup and every hour the gate reads DSH's workspace store
($DSH_HOME/storages/workspace.json, read-only) and classifies every session it
holds authorization-chain data for. Sessions DSH has archived (global.archivedSessionIds)
or no longer tracks at all have their decision events dropped from
$DSH_HOME/perm-gate/events.jsonl and their pre-change snapshot files deleted
from $DSH_HOME/perm-gate/snapshots/ — history the review page can no longer
reach, for data the harness itself considers gone. Live sessions are untouched,
unattributable rows (empty session id) are never deleted, and any failure is
fail-open: the round is skipped and retried an hour later. Set sessionSweep: false
to disable; workspaceStoreFile overrides the store path. Restoring an archived
session does not restore its swept history.
Rules file
permissions:
defaultAction: ask
deny:
- command: [rm#recursive]
reason: no recursive rm
- command: [ssh]
reason: no direct ssh
- paths: [.dsh/**]
reason: protect harness metadata
allow:
- command: [pnpm, node]
reason: dev tools
- command: [curl, wget]
args: ["https://*.example.com/*"]
reason: allowed endpoint
ask:
- command: [bash, sh]
reason: ask shells
A command entry word#flag matches the command word (word) with the modifier recursive or
force — so rm#recursive matches rm -rf, env rm -rf, and sh -c "rm -rf /".
Network policy (opt-in)
A local HTTP/CONNECT proxy that adjudicates the outbound traffic of shell subprocesses against the same rules file, plus an approval path for targets no rule covers. Off by default — enabling it binds a loopback port and rewrites the proxy environment for child processes, so it is never turned on implicitly.
- id: dsh-perm-gate
config:
networkEnabled: false # master switch (default false)
networkMode: whitelist # deny-all | whitelist | allow-all
networkUnlisted: ask # ask | deny — unlisted target handling
networkUnattributed: allow # allow | deny — traffic with no shell attribution
networkInjectEnv: true # rewrite HTTP(S)_PROXY/ALL_PROXY for children
networkAskTimeoutMs: 120000 # approval wait before failing closed
networkGrantTtlMs: 1800000 # how long one approval covers its target
Tiered behaviour. Nothing reaches the network without an allow rule.
An unlisted target is escalated to the interactive approval seam, raised on
behalf of the shell command that opened the connection; approving widens reach
for that target for the session. A deny rule is never escalated —
approval can widen what an unlisted target may reach, but it can never override
a rule that says no.
The boundary — read this before relying on it. The proxy is a cooperative policy layer, not an enforcement boundary. It only sees traffic from clients that read the proxy environment:
| Client | Covered? |
|---|---|
curl, wget, git, Go net/http, Python requests |
yes |
Node.js http/https/fetch |
no — connects directly |
Java (without -D proxy flags), .NET HttpClient |
no |
| Raw sockets, custom TCP | no |
| DNS, QUIC/HTTP3, non-HTTP protocols | no |
| Connections to a literal IP | no |
So a shell command like node -e "require('http').get('http://host/')" is not
intercepted. Treat this as a guardrail against accidents and a place to state
intent, not as a hermetic sandbox.
DSH's own network traffic — the built-in network tools and the LLM
transport — is deliberately left alone. Those connections carry no shell
attribution, and networkUnattributed: allow (the default) passes them
through unreviewed: reviewing them would let the host block itself, which is
a worse failure than a missed block. Set networkUnattributed: deny only if
you know your host's clients ignore the proxy environment.
Query the live state at GET /api/dsh-perm-gate/network (mode, bind, port,
proxy liveness, env-injection state, block counters, recent blocks).
The 自动审查 tier (machine value permissive)
自动审查 is an independent approval tier in the DSH permission picker, parallel to Read Only / Workspace Write / Full access / Whitelist. It is not generic "auto-approval" and never mints blanket authority: it only narrows or widens the seam before the human/LLM step while P0 hard-deny stays monotonic and non-negotiable within the gate's own scope.
P0 is scoped, not global. The gate acts only while the session's permission preset is one of
gatePresets(defaultpermissive/permissive-full). Under any other preset — Read Only, Workspace Write, or Full access — the entire gate stands down, P0 hard-deny included, because the selected tier's own policy governs that session. This is deliberate (seegatePresetsin the configuration table), but it means "P0 is non-negotiable" holds inside the gate's tiers rather than across every tier. A stand-down is not silent: the gate records onestand-downevent per session/preset transition and the browser shows a sticky GATE OFF strip above the input. SetgatePresets: ['*']to make P0 global again.
Two variants ship, because a preset's sandbox and approval are independent knobs and
coupling them forced a bad trade:
| Picker label | Machine value | sandbox | approval |
|---|---|---|---|
| 自动审查 | permissive |
workspace-write |
ask |
| 自动审查(高权限) | permissive-full |
danger-full-access |
ask |
The plain tier keeps the built-in file sandbox. That sandbox also denies the named pipes a child
process needs to start, so git clone, MSYS2/Cygwin sh.exe and ConPTY fail under it with
Win32 error 5 / couldn't create signal pipe. Because the gate is active only in the tiers
listed in gatePresets, wanting the gate meant accepting that restriction. 自动审查(高权限)
removes the coupling: identical approval behaviour, no file-sandbox restriction. The tier's own
description states the trade plainly — the workflow is smoother, approvals still apply per call,
but there is no system sandbox left as a backstop. Both are in the
default gatePresets, so either one gives you the full P0–P4 chain — the gate reads the preset
name only, never the sandbox mode.
The picker label is a host-supplied product string, not a per-locale dictionary entry: DSH
renders a plugin tier's name: verbatim on both permission surfaces (the General-settings
default row and the composer picker) and only supplies its own localized labels for the three
built-in values, so cordis.patch.yml ships the Chinese label for every session.
The icon is a different story. The composer's glyph map is closed, and its own comment states
the rule: host-configured names outside the design set get none. permissive is a built-in
value, so 自动审查 already has a shield+eye glyph; permissive-full gets the same glyph only
because npx dsh-perm-gate-patch-glyph adds it to that map. That patch edits a host
package, so it is lost on every DSH upgrade — see
After a DSH upgrade.
In cordis.yml:
- id: dsh-perm-gate
name: dsh-perm-gate
config:
rulesFile: ./permissions.yaml
defaultAction: ask
permissive: true # single front-facing switch (independent tier on)
permissiveStrategies: # backend, combinable
trustAutoAllow: true # in-scope safe ops auto-allow; dangerous/unknown ask
alwaysConfirm: false # every crossing asks; allow-controls add repeat-allow / wl-migrate buttons
llmAssist: false # LLM classify first, human fallback on ask/failure
trustEscalation: true # a cleared call's own sandbox escalation needs no prompt
trustAutoAllow is the baseline middle tier (rule-allow auto-passes). alwaysConfirm surfaces
the approval panel for every crossing; its "allow controls" add two extended buttons —
repeat-allow this type this session (a bounded session grant) and allow every occurrence
(which persists the command word into the permissions.yaml allow whitelist via
approveAllowEverywhere). llmAssist consults a real, configurable LLM (classifierEndpoint /
classifierModel, any OpenAI-compatible API) to auto-decide an ask, and falls back to the
human seam on ask/error — always fail-closed. trustEscalation (on by default while the tier
is on) answers a sandbox_permissions escalation raised from inside a call the gate already
allowed; see below. When permissive is off, the gate behaves exactly as before.
Sandbox escalation: why a safe verdict still prompted
A tool call can raise two independent approvals. The gate owns the first — its own ask, on
the tools/pre-execute waterfall. The second comes from approveEscalation inside the tool
body, at tools/execute time, whenever the model passed sandbox_permissions +
justification; by then tools/pre-execute has already settled, so the gate's allow never
reaches it. A call the LLM graded safe and the gate auto-allowed therefore still showed a
prompt asking you to approve the sandbox widening.
trustEscalation closes that gap. The gate remembers every call it positively allowed (keyed by
the host's callId, which the escalation request repeats) and answers the escalation
allowed-once itself. It applies only when all hold:
- the 自动审查 tier is on and
trustEscalationis on; - the request carries a
callIdthe gate cleared, with a matching tool name; - the reason is a recognized escalation naming
workspace-writeordanger-full-access.
Everything else — an unrecognized reason, a different call, a call the gate asked or denied, the
approval: never passthrough — delegates to the human unchanged, so a future DSH wording change
fails closed rather than open. The auto-answer is recorded on the event feed
(verdict: "escalation-auto", mode: <target>). Turn the switch off to keep widening
human-gated while other allows stay automatic.
Risk-graded llmAssist, verdict learning, and the event feed
When llmAssist is on, the configured LLM grades one ask at a time with a structured
protocol. Grading happens inside the gate's tools/pre-execute waterfall, before the decision
is returned to the host: a safe verdict delegates the call straight through, so the approval
panel never appears; only a genuinely uncertain verdict reaches you. Two receiver sources are
selectable in the settings card: a custom API (any
OpenAI-compatible endpoint — classifierEndpoint / classifierModel / classifierApiKey,
with presets including Xiaomi MiMo https://api.xiaomimimo.com/v1), or the DSH host model
group (the session's configured llm service, via agentDefaultModel.currentSelection,
optionally overridden with classifierProvider / classifierModel). A health test button
(POST /api/dsh-perm-gate/health) runs one minimal completion and reports latency.
safe→ the call is auto-allowed (audited as theclassifiersource); no panel is shown.risky+ a hard category (deletion,credential,remote,system,bulk) → the call keeps the human ask. The classifier never denies: the deny path belongs to the deterministic layers alone (P0 hard-deny, the deny-keyword blacklist, explicitdeny:rules), so a misgraded category is always negotiable instead of an unappealable block. Hard categories stay distinct fromneutralin one way that matters: they are never learned, so repeated approvals can never sediment them into an auto-allow. (Denying on the model's word was measured live: a benigngit commit -F …gradedremoteproduced an auto-deny with no panel and no grant to retry with.)risky:neutral→ withriskLearningenabled (Settings card, off by default), each human approval that actually executes (settled via the host'stools/resultevent) counts toward atool|categorykey; once the count reachesriskThreshold(default 3) and the new call's operation fingerprint (command word + target basename) matches a confirmed sample, the exact same operation auto-allows. A different target never reuses that authority. With sedimentation (riskSediment, on by default) a threshold-reached key's confirmed samples become deterministic allow rules: an exact hit allows outright without another LLM call — even withllmAssistoff — and the sedimented rules are visible and manageable (terminate / remove) in the settings card.- Timeouts (
riskTimeoutMs, default 20 s, one retry), transport failures, and off-protocol model output leave the ask untouched — the gate never guesses.
Learning state persists to a plugin-owned JSON ($DSH_HOME/perm-gate/learning.json, or
learningFile), never into your YAML rules file. Every decision is appended to
$DSH_HOME/perm-gate/events.jsonl (or eventsFile) and served at
GET /api/dsh-perm-gate/events?sessionId=&since=; the browser half polls it and shows the
latest decision as a notice strip above the conversation input (asks stay visible until the
next event) and lists the whole session's decisions newest-first in the Approvals tab of
the conversation view.
Every decision's affected files are snapshotted before the change lands (≤5 files, ≤256 KB each)
under $DSH_HOME/perm-gate/snapshots/; in the Approvals tab each file chip opens a line diff
(GET /api/dsh-perm-gate/diff) with a revert action that delivers a restore instruction into
the conversation (POST /api/dsh-perm-gate/revert). A snapshot inventory bar clears them per
session or entirely (GET /api/dsh-perm-gate/snapshots-stats / POST /api/dsh-perm-gate/snapshots-clear).
An ask the gate routes to a human is tracked until the human answers: a passive
approval/request observer records the closed outcome (allowed-once → approved,
rejected → rejected, cancelled → cancelled, unavailable → a denial, since no approval
channel existed), with tools/result settling the same ask as a fallback when the observer cannot
correlate it. Approvals report the post-approval learning progress (n/threshold), and the notice
strip labels all three terminal states.
自动审查 and 自动审查(高权限) both draw the shield+eye glyph in the picker — the first from DSH's built-in map, the second from the host patch the installation guide describes. Without that patch the second tier is text-only on every surface; its label and its gating are unaffected.
A selectable session tier
cordis.patch.yml adds a permissive preset (sandbox: workspace-write, approval: ask, name
自动审查) between Workspace Write and Full access. The DSH bundle patch replaces the whole
permission.config.presets map rather than merging per key, so the file also restates the three
built-ins (read-only / workspace-write / danger-full-access, from
@deepseek-ai/dsh-base/cordis.patch.yml); test/patch-presets.spec.ts pins that key set. So the
session permission picker offers 自动审查 as an independent selectable approval tier, not a
generic "auto-approval" mode.
The gate is active only in the tiers listed in gatePresets (default
['permissive', 'permissive-full'], the two tiers this plugin adds). In every other tier —
Read Only, Workspace Write, Full access, custom — the gate's decision flow does not run at all:
no allow, no ask, no deny, no P0 hard-deny, no deny-keyword veto, and no audit event. The
selected tier's own policy governs the call, which is the point: the built-in
danger-full-access is defined as "full access without approval prompts", so overruling it with
an ask (unanswerable there — the approval seam rejects before any answerer runs, producing
the user rejected tool "..." with no panel) or with a hard-deny would silently contradict the
tier the user chose. gatePresets: ['*'] makes the gate global again (hard-deny included);
inside an active tier an ask is still degraded to passthrough when the session's effective
approval policy is never — which is why both 自动审查 tiers declare approval: ask.
Configurable in the UI
The tier is also adjustable at runtime from Settings → Plugins → 自动审查
(a settings.plugins.tab page rendered by the plugin's browser client): one switch toggles
permissive, and four toggles edit the backend permissiveStrategies. The host reads the
namespace live, so a change applies to the next tool call without a restart. This is an
independent approval class, NOT a generic "auto-approval" mode.
Rule test (dry-run)
The same page carries a Rule test panel: type a tool name and a command, press Test, and the gate judges that call against the ruleset it currently has loaded — without running anything and without writing any rule. You get the verdict, the matched rule (index and action), the dimensions that rule constrains, and the reason.
It reports the effective verdict (the whole P0 → P1 → P2 → P3 → P4 chain) and the rule layer's own
answer, which are not the same thing: a P0 hard-deny or a preset deny-keyword fires before the rule
chain and leaves no rule index behind, so the panel says "no rule matched" rather than naming an
unrelated rule. A 0 rules loaded note means the rulesFile path resolved to nothing.
The panel talks to POST /api/dsh-perm-gate/dry-run, which is read-only by construction — it
has no write form at all, so testing a rule can never change it.
CLI
Dry-run one call against a rules file (no harness needed):
dsh-perm-gate --rules permissions.yaml --tool bash --args '{"command":"pnpm install"}'
dsh-perm-gate --rules permissions.yaml --list
Development
npm run typecheck
npm test
npm run build
No comments yet. Be the first to write one.