7starsseeker/dsh-jev-guard

dsh-jev-guard

DSH 执行前安全阀门,用 TypeSafe Jev 模型做判定:挂载 tools/pre-execute,对每条 bash/pwsh 调用先过离线静态规则,再向 Jev 模型(TypeSafe 的 System One 模型,返回结构化判定而非散文)提一个是非问句——「这条命令会不可逆地删除或覆盖真实数据吗?」——把答案切成允许/修正/拦截/上报人工四态;修正给模型更安全的写法,上报提供一次性人工令牌;额度耗尽或没有可用密钥时大声降级而非静默失效,缺密钥时会直接在对话里要求录入(录入用 `guard key set`,只从标准输入读);每条判定写入共享审计日志,面向人的文案中英双语。

미분류cordis-plugindeepseek-harnessdeepseek-harness-plugin

dsh-jev-guard

English | 简体中文 | Changelog | Design decisions | Measurements

A pre-execution safety valve for DeepSeek Harness (DSH): before a command actually runs, it asks one question — "will this irreversibly delete or overwrite your real data?"

It judges with TypeSafe Jev — a "System One" model that produces no prose, only a structured decision. Yes/no questions are what this kind of judgment measures most accurately (114-case calibration, 90.4% with the Chinese text sent as-is). It hooks into DSH's native tools/pre-execute, so it is a hard interception, not a "please be careful" nudge to the model.

command text ──▶ L0 static hard rules (offline · cannot be overridden) ──▶ pre-screen (read-only / rebuildable) ──▶ Jev semantic judgment (~300ms)
                        │                       │                        │
                        └───────────────┬───────┴────────────────────────┘
                                        ▼
                        allow / revise (with downgrade templates) / block / escalate
                            │        │              │          │
                        run it   model rewrites   refuse    DSH approval prompt or one-shot token

中文说明 — 本文档为英文版,完整中文介绍见 README.zh-CN.md。dsh-jev-guard 是 DeepSeek Harness 的执行前安全阀门:每条 bash / pwsh 工具调用执行之前先判定一次(先离线静态规则,再付费语义模型),返回 allow / revise / block / escalate 四态之一。它会拦下不可逆的命令,能教模型更安全的写法时就给出降级模板,两者都不适用时提供一次性人工令牌;额度耗尽时大声降级而不是静默失效。它是事故安全网,不是安全边界 —— 见 Known limits。


Table of contents

What it does

VerdictMeaningWho acts next
allowProvably read-only, or judged safeRun it
reviseNot enough evidence to run safely, but a better form very likely existsThe model rewrites and retries (three downgrade templates: dry run first / narrow the scope / back up first)
blockWould irreversibly delete or overwrite, or hit a hard ruleRefused; a human runs it by hand
escalateA human must confirm (the hard rules' "always ask" class, or the retry budget is spent)A human

Three layers, always in this order:

  1. L0 static hard rules (lib/rules.js): 26 "never allowed" + 24 "human confirmation required". Offline and impossible to override — not even a one-shot token gets past them. Rules match at command position only (the start of a line, or after ; & | ( $(, or after bash -c " / pwsh -NoProfile -Command " / cmd /c ", and skipping wrappers such as sudo / timeout 30 / xargs -0 / find … -exec) — so mentioning a dangerous command inside an argument is not a false positive, and a real command buried in a multi-line script is not missed either. Only two rules cannot be anchored structurally (redirect-to-device, fork-bomb) and match the whole text; the counter RULE_STATS.anywhere is always 2.
  2. Pre-screen: commands that are provably read-only or touch only rebuildable content (caches, build output, /tmp) pass straight through, with zero network calls.
  3. Jev semantic judgment: one yes/no question — "will this command irreversibly delete or overwrite the user's real data?" — split by two thresholds into allow / revise / block. Measured latency ~300ms (P50 267ms), cost ≈ $0.000019 per call.

Install

Requires Node ≥ 20 (it uses the global fetch). Zero runtime dependencies — no npm install needed.

Verified host versions: DSH 0.1.6-alpha.2, 0.1.7-rc.2, 0.2.0-rc.2, 0.2.1-rc.2 and 0.2.1-rc.3. 0.1.7-rc.2 was checked end to end on 2026-09-25 (Node 24.21.0, WSL/Linux, this plugin at 0.5.3): guard selftest 12/12; the seven offline suites 20 / 31 / 34 / 109 / 56 / 48 / 17 assertions, all passing; the DSH adapter smoke test 23/23; and the tool-pipeline integration test running against that checkout's own @deepseek-ai/dsh-tools 6/6 offline and 7/7 with a key, the extra case being one live judgement through the real five-stage pipeline. The valve also ran mounted in a real session, judging commands and writing its audit log under approval policy ask.

0.2.0-rc.2 was checked end to end on 2026-09-30 — this time the host was upgraded (@deepseek-ai/dsh-desktop-runtime 0.2.0-rc.2, Node 24.12.0, Windows), with this plugin at 0.5.3 and unmodified. The valve ran mounted in a real session: it judged the agent's commands over the live model (jev-1.13.0) and wrote its audit log, every record carrying the session id, the decision source (jev / prefilter / static-rule / cache), the risk probability, the latency and the token usage. Fail-open showed up in production as well as in the tests: judgements that ran past the 1800 ms budget were logged as source: error and allowed through, exactly as designed. Battery on that host: guard selftest 12/12; the seven offline suites 20 / 30 / 34 / 109 / 54 / 48 / 17 assertions, all passing (two counts differ from the Linux run — selftest-entry 30 vs 31 and selftest-reason 54 vs 56 — because those suites branch on platform); the DSH adapter smoke test 23/23; and the tool-pipeline integration test on the real @deepseek-ai/dsh-tools 0.2.0-rc.2 five-stage pipeline 6/6 offline and 7/7 with a key, the extra case being one live judgement through the real pipeline. Statically, every host file this plugin touches is byte-identical between 0.1.7-rc.2 and 0.2.0-rc.2 — the tools/pre-execute and agent/pre-step declarations among them — so no code change was needed and none was made. The host's plugin version gate, which denies a plugin whose declared @deepseek-ai/dsh* peer requirements the running version does not satisfy, does not apply here: this plugin declares no peerDependencies, and both the gate's own function and the real profile-assembly path admit it.

0.2.0-rc.2 was re-checked on 2026-10-15, with this plugin at 0.5.5 on the same Windows host: guard selftest 12/12; the seven offline suites 20 / 30 / 34 / 113 / 54 / 48 / 17 assertions, all passing; the DSH adapter smoke test 23/23 without a key and 25/25 with one; and the tool-pipeline integration test 6/6 offline and 7/7 with a key. The valve spent that day mounted in a real session, and the two failure shapes the session produced are now classified and degrading instead of filed under unknown: an egress region block (451) and a mistyped model (400) — see D17. A judgment that outruns its budget still fails open and is still recorded as source: error.

0.2.1-rc.2 was checked on 2026-10-17, with this plugin at 0.5.6 on the same Windows host. The check was a contract diff rather than a guess: every host surface this plugin stands on was compared between the 0.2.0-rc.2 packages and the 0.2.1 build — the tools/pre-execute waterfall and its allow/ask/deny decision shape (dsh-tools/lib/types/index.js), the agent/pre-step default {kind: 'enter', messages} decision (dsh-agent-loop/lib/index.js), snapshotEvents / deriveMessages and the approval/policy + permission/preset payloads — and the lines the plugin actually matches are byte-identical, so no adapter change was needed. The valve then ran mounted: 216 judgments across three sessions (allow 212 / revise 2 / block 2), and the only eight failures were 1800 ms timeouts — fail-open exactly as designed, not a contract break. Battery on that host: guard selftest 12/12; the seven offline suites 30 / 34 / 113 / 54 / 17 / 83 / 20 assertions, all passing; the DSH adapter smoke test 23/23. On the same day this profile's executor flipped from bash to pwsh (Git Bash's MSYS2 runtime cannot start under the sandbox's WRITE_RESTRICTED token, see cordis.patch.yml), which is what 0.5.6 answers: the audit log now carries "tool": "pwsh" records, and PowerShell's destructive surface is in the L0 rules (D19). The real tool-pipeline integration test was not run in this workspace — it resolves @deepseek-ai/cordis from its own location, and this checkout is deliberately zero-dependency; neither CI nor the publish workflow runs it.

0.2.1-rc.3 was checked on 2026-10-24, with this plugin at 0.5.7 on the same Windows host (Node 24.22.1, cordis 4.0.5). The check was again a contract diff rather than a guess — and this time the packages diffed were the ones inside the shipped app.asar, extracted by dependency closure, not a published tarball: tools/pre-execute is still the waterfall at dsh-tools/lib/index.js:3321 with its {kind: 'allow'} default and the same allow/ask/deny/cancel decision shape (dsh-tools/lib/types/index.js:885); agent/pre-step still defaults to {kind: 'enter', messages} at dsh-agent-loop/lib/index.js:909; approval/policy and permission/preset are still appended and read back at dsh-user-approval/lib/index.js:65 / :163 and dsh-permission-presets/lib/index.js:359 / :380 / :87-90; and dsh.bundle.patch still takes a single path string (dsh-app-boot's bundlePatchFiles). Every line the plugin matches is unchanged, so no adapter change was needed and none was made. The tool-pipeline integration test the 0.2.1-rc.2 round could not run here now runs: against those rc3 packages, tools/smoke-dsh-pipeline.mjs is 6/6 offline — a read-only command really executes through the five-stage pipeline, git push --force is stopped before the tool body runs, git reset --hard becomes an ask and is denied when no approval channel answers, a non-matching tool passes untouched, and the notice survives the host's v4 admission check with 0.5.6 旧形态被拒=true. The valve also spent the day mounted: 264 judgments in one rc3 session, all allow — 244 judged by the model (jev-1.13.0, with policy: never and preset: danger-full-access read from that session's live events), 2 cache hits, and 18 that overran the 1800 ms budget and failed open exactly as designed (a latency observation, not a contract break: the semantic layer measured 1.25–2.31 s per judgement that day). Battery on that host: guard selftest 12/12; the seven offline suites 30 / 34 / 113 / 54 / 17 / 83 / 20 assertions, all passing; the DSH adapter smoke test 24/24.

No host requirement is declared in package.json for any of them: the plugin market reads that field from the npm manifest and would then block install and update on every other DSH release. A version not listed here is therefore untested, not forbidden; if you run one, re-run the self-checks below.

# 1. Put this repository somewhere permanent, e.g. T:\dsh-jev-guard (/mnt/t/dsh-jev-guard in WSL)

# 2. Let DSH load it (fill in your own profile name)
dsh plugin --profile web add /mnt/t/dsh-jev-guard      # on Windows: T:\dsh-jev-guard

# 3. Restart DSH (plugins are not hot-reloaded)

dsh plugin resolves add through pnpm, so the spec takes anything pnpm accepts. The published package is on npm — the source the plugin market installs from by preference:

dsh plugin --profile web add dsh-jev-guard

A local path is linked, so the plugin keeps running from your own checkout — edit a file, restart, done. To install straight from GitHub source instead, use github:7starsseeker/dsh-jev-guard.

None of these has anything to build (zero dependencies, no install scripts), so none raises a build-approval prompt.

A fresh install has no key, and it says so instead of going quiet. The first session tells you in the conversation itself that no key is configured, and until you record one the valve runs degraded: the free L0 hard rules and the pre-screen still work, the paid semantic layer does not. Recording a key is one command, and it is read from stdin — never from an argument, which would land in your shell history and in ps:

node bin/guard.mjs key set        # paste the key, Enter; never echoed, never in shell history
node bin/guard.mjs key status     # which source resolves, and how long it is (never the value)

guard key status exits 3 when no key resolves, so it works as a health check. There is no cooldown to wait out: the moment a key resolves, the degraded state is cleared and judging resumes on the next command.

The declaration in package.json is a standard DSH bundle:

{
  "name": "dsh-jev-guard",
  "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}

cordis.patch.yml mounts the plugin on tools/pre-execute, and every tunable lives there (you can also put them in config.json; precedence is patch > config.json > built-in defaults).

API key — three sources, highest precedence first: the DSH credentials layer (ctx.credentials, rotation needs no restart) → the environment variable TYPESAFE_API_KEY → a secrets.json you create yourself in the package root ({"TYPESAFE_API_KEY": "apikey_..."}). That file is in .gitignore and is deliberately not part of the repository or of the published package, so nobody ships one to you — the two sources above it are the ones to prefer. node bin/guard.mjs key set writes that file for you (mode 0600), and the DSH adapter reads it too. The key is never printed, and it is masked before anything is written to the log.

Verify right after installing (don't settle for "it didn't error"):

node bin/guard.mjs selftest      # expect: all 12 checks pass (offline)
node bin/guard.mjs status        # expect: ✅ healthy (exit code 3 while degraded)

Then run a command in a session that is certain to be blocked (e.g. git push --force origin main): it should be refused, and that record should appear in node bin/guard.mjs log --tail 3.

Languages

Everything a human or a model reads — verdict reasons, the 37 L0 rule reasons, all CLI output, degradation warnings and guard status — exists in English and Chinese.

KnobWhat it controlsDefault
langThe language of the messages above'auto' — resolves from JEV_GUARD_LANG → LC_ALL/LC_MESSAGES/LANG (only when they name a language we ship) → otherwise zh-CN
CLI --lang zh-CN|enThe same thing, for one invocation—
promptLangThe language of the question sent to Jev and of the state keys it reads'zh-CN'

promptLang is not a translation setting — it is a judging parameter. The 0.5 / 0.7 thresholds were calibrated against the Chinese question (114 labelled cases), so switching it moves a measured boundary. Measured (tools/probe-prompt-lang.mjs, 21 probes × 3 repeats per arm × 2 independent runs):

Same band as the Chinese question18/21
Mean absolute probability gap0.049
Signed mean (negative = the English question is more permissive)−0.043
Lower / higher / level12 / 4 / 5
Repeat-sampling noise (same question, same state)0.015

Three commands changed band, all in the permissive direction: UPDATE without WHERE (block → revise), DELETE … WHERE (block → revise), inline node -e rmSync (revise → allow). Two runs agreed. So if you set promptLang: 'en', either re-calibrate or lower both thresholds by ≈ 0.04 — see docs/MEASUREMENTS.md §14 and D14 in docs/DECISIONS.md.

Code comments and the labels inside tools/ stay Chinese on purpose: they are read by maintainers of a Chinese-first codebase, and translating them would double the upkeep of every future change without changing a single word the product says.

Why auto does not look at the system locale: it used to fall through to Intl, and that bit us on the first real deployment — inside WSL LANG=C.UTF-8 means "no preference", Intl then reports Node's own en-US default, and the session suddenly spoke English while the Windows-side CLI stayed Chinese. C/POSIX/unset counts as absence of a signal and lands on zh-CN; a locale that really names a language (en_US.UTF-8, zh_CN.UTF-8) still resolves on its own.

中文读者:完整中文介绍见 README.zh-CN.md。

Configuration

config.example.json is the template; every key is listed below (the defaults live in DEFAULTS in lib/gate.js).

KeyDefaultDescription
modeljev-1.13.0The judging model, pinned. The service's aliases move under a released plugin and this repository's thresholds were calibrated on 1.13.0: measured on 2026-10-15, jev-latest still resolved to jev-1.13.0 while jev-preview resolved to the image-mode jev-1.14.0, which is deliberately not adopted: every judgment is one text question, so an image-mode model would carry a capability this plugin never calls, and upstream's own position is that it is not ready for production
endpointhttps://api.typesafe.ai/v1/systemoneJudging service endpoint
apiKeyEnv / apiKeyFileTYPESAFE_API_KEY / secrets.jsonKey sources; relative paths resolve against the package root (independent of cwd)
langautoLanguage of every human/model-facing message (see Languages); auto reads the environment only, falling back to zh-CN
promptLangzh-CNLanguage of the question asked to Jev and of the state keys. A judging parameter, not a translation: see Languages — switching it to en was measured to shift ≈ −0.04 and to flip three probes toward permissive
lowThreshold / highThreshold0.5 / 0.7The four-state thresholds: p < low → allow; low ≤ p < high → revise; p ≥ high → block. Over 737 real commands this split 98.51% / 0.81% / 0.68%
reviseInAskMode / blockInAskModeask / askHow the verdict action branches by approval policy: ask = hand it to a human approval prompt when approval is available (a human is right there — a 50%-confidence judgment should not be deciding for them); deny = fall back to a plain refusal. Under never (fully automatic) both stay a plain refusal. L0 deny-class hard rules are unaffected by this switch — they always block
timeoutMs1800Budget for a single judgment; anything over it is allowed (fail-open)
cacheSize256Number of cached judgments
inlineScripts / maxScriptBytestrue / 8192Read the body of an invoked script into the judgment context (measured: lifts the node x.mjs blind spot from 0.31 to 0.82); sensitive paths are skipped automatically
retryLimit2How many times the same command may be blocked before it escalates (escalate, i.e. handed to a human)
tokens / tokenPathtrue / ~/.jev-guard/allow.txtOne-shot allow tokens
logPath / logMaxBytes~/.jev-guard/guard.log / 4 MiBShared audit log (JSONL, rotated when over the limit)
quotaCooldownMs / authCooldownMs15 min / 30 minCooldown after quota- and key-class failures. The two classes added in 0.5.5 are fixed rather than configurable: region (451) 30 min, request (400/422) 15 min
degradePolicyl0-onlyWhich layer survives degradation: l0-only (stop only the paid semantic layer) or off (suspend the whole valve)
pricePerMTok0.042Unit price for cost estimation (USD per million input tokens; output is free per the vendor's documentation)

Behavior under the two approval policies

The same set of verdicts lands differently under DSH's two session policies — this is the part people mix up most:

Verdictapproval: ask (prompts)approval: never (full access / YOLO)
revise (50–70%)Handed to a human approval promptRefused + downgrade templates + one-shot token hint
block (≥70%, semantic layer)Handed to a human approval promptRefused + one-shot token hint
L0 deny-class hard rulesRefused (no prompt, no token)Refused
L0 ask-class rules (escalate)DSH approval prompt, the human decidesRefused + one-shot token hint

Why ask mode hands both the grey zone and the high scores to a human: when a person is right there, letting a 50.6% judgment decide for them makes no sense; and under never there is nobody to ask, so the valve can only refuse conservatively. When the host has no responder, approval is fail-closed, so handing something to a human does not turn into automatic approval while unattended; the prompt only ever offers allowed-once, never a lasting bypass.

L0 deny-class hard rules are an absolute gate: they block under both policies, and even retryLimit's "retry enough and a human handles it" does not apply to them — otherwise one click on "allow" in a prompt would bypass the hard floor (a token cannot cross L0, and neither can an approval).

danger-full-access = { sandbox: 'danger-full-access', approval: 'never' } — no sandbox underneath, approval effectively off: the valve is the only layer left. That is exactly why it exists, and it is also the situation where a wrong verdict costs the most. Audit records carry the sandbox preset (preset) and the approval policy (policy) for that call, so a retrospective can tell whether a sandbox was still behind it at the time.