dsh-decision-gate
A PreToolUse decision gate for DSH — asks five yes/no questions about every shell tool call, then allows, asks, or denies it, with a swappable judge backend.
Calibrated thresholds, a path-scoped exemption, and a JSONL decision log — because a gate that cannot reach its judge must never become the thing that blocks the harness. How it works →
English · 简体中文 · How it works · Judge backends · Install · Verify · Changelog
A DSH (DeepSeek Harness) PreToolUse gate. Before every shell tool call
(bash, pwsh, terminal_*) runs, the plugin asks a judge five yes/no
questions about the operation, maps the answers through fixed thresholds to
allow / ask / deny, and appends every decision to a JSONL log so the
thresholds can be calibrated later against real data.
The gate has no power to escalate anything. Its one job is to stop the session
from silently doing something a person would not have wanted — and to stay out
of the way otherwise.
Version compatibility. Verified against DeepSeek Harness
0.2.1-rc.2 (the desktop RC): the in-process gate rides the
tools/pre-execute seam that every RC since 0.2.0-rc.1 carries, and the
supported range (>=0.2.0-rc.1 <0.3.0-0) is now declared in package.json
engines.dsh — the field the harness's own plugin-compatibility gate
reads, so an incompatible DSH skips this bundle loudly instead of loading
it. The hooks.json form in this repo is the reference/regression twin,
not the live gate; running it needs the optional
@deepseek-ai/dsh-hooks-claude-code bridge, which the 0.2.1-rc.2
desktop build does not bundle (install it from the plugin market if you
want that form). Requires Node >= 20.
How it works
For each gated tool call the plugin sends the tool name and arguments (clipped
at 8000 chars per string) to TypeSafe's SystemOne endpoint with five noul
questions:
| signal | what it asks |
|---|
read_only | does this only inspect/read/display, changing nothing? |
destructive | would this delete/overwrite data a person could not restore? |
secret_exposure | would this send or print secret values anywhere? |
external_irreversible | would this change state outside this machine irreversibly? |
routine_dev | is this a routine local development step? |
The first rule that fires wins, in this order:
secret_exposure >= 0.50 → deny
destructive >= 0.50 → ask when exempt, otherwise deny
external_irreversible >= 0.50 → ask
read_only >= 0.80 → allow
routine_dev >= 0.75 → allow
- otherwise → ask (
unclear)
Every failure path — no API key, timeout, non-JSON answer, wrong answer shape
— degrades to next() and logs the error. A gate that cannot reach its
judge must never become the thing that blocks the harness.
Path-scoped exemption
The gate has no notion of context, so rm scratch-note.txt and rm -rf /
score identically: destructive, deny, no approval channel. That leaves the
agent permanently unable to clean up its own scratch files, because a deny
cannot be overridden from inside the session.
Exemption fixes exactly that one case: when every path a command writes to
lives inside an exempt prefix (default ['.agent-scratch']), the destructive
deny is downgraded to an ask — a person still sees it, but the agent is
not locked out. The parser is deliberately shallow and fails closed:
secret_exposure denies are never downgraded.
- one non-exempt target disqualifies the whole command.
- unknown verbs, unparseable quoting,
cd then rm, find -delete,
xargs rm, symlink escapes — all keep the original deny.
- a write verb followed by more statements on later lines: a bare newline is
whitespace, not a command separator, so the following tokens join the verb's
argument list as extra targets — extra targets can only ever make a command
less exempt, so this too fails closed. Keep destructive commands to a
single statement when you want the exemption to apply.
rm -rf .agent-scratch/old-logs # ask (exempt_destructive)
rm -rf src # deny (destructive)
cat .agent-scratch/keys.txt # deny (secret never downgrades)
rm .agent-scratch/a src/b # deny (one target outside the prefix)
Judge backends
The gate needs a model that answers five yes/no questions with probabilities.
Two backends, chosen by the judgeProvider config key:
typesafe (default) — TypeSafe's SystemOne endpoint with calibrated
noul probability questions: sub-second answers, and the thresholds mean
what they say on that calibrated scale. Needs TYPESAFE_API_KEY, and some
networks cannot reach it: a region-blocked egress gets HTTP 451 "not
available in your region", which fails every call open. Configure
typesafeProxy (or HTTP_PROXY/HTTPS_PROXY) to route around that.
dsh-llm — the same five questions go through the session's own LLM
service, following the provider/model the DSH profile configures: no extra
credential, no endpoint of its own, so no region or proxy question at all.
The cost: every gated call waits for that model (pin a fast one via model /
judgeRoute), and its self-reported probabilities are not on the calibrated
scale the thresholds were tuned against.
Both backends converge on the same Signals object, so thresholds,
exemption, and log format are identical whichever judge answers. A judge that
errors, times out, or answers out of protocol fails open — the gate logs the
reason and lets the call through.
Configuration
Config keys (profile cordis.patch.yml), all optional:
| key | default |
|---|
judgeProvider | typesafe |
matcher | ^(bash|pwsh|terminal_) |
model | backend-relative: jev-1.13.0 on typesafe; unset on dsh-llm means follow the session route |
judgeRoute | unset: follow the session's live provider/model; set {provider, model} to pin both |
logPath | <plugin dir>/decisions.jsonl |
typesafeApiKey | unset: a role('secret') field, edited masked in the form and held by the credential store |
typesafeApiKeyEnv | TYPESAFE_API_KEY — the environment variable read when no key is configured |
typesafeProxy | unset: proxy URL for the typesafe endpoint (region-block workaround); empty reads HTTP_PROXY/HTTPS_PROXY, then connects directly |
destructive | 0.50 |
secretExposure | 0.50 |
externalIrreversible | 0.50 |
readOnly | 0.80 |
routineDev | 0.75 |
exemptPrefixes | ['.agent-scratch'] |
authorizedOperations | [] — commands whose external-irreversible ask is downgraded to allow, matched per statement |
Auth: the dsh-llm backend needs no extra credential — it rides on the
credentials DSH already resolved for the session's provider. The typesafe
backend resolves its key by candidates, profile config first:
typesafeApiKey (a role('secret') field — masked in the form, held by the
credential store, never echoed) → the environment variable named by
typesafeApiKeyEnv (TYPESAFE_API_KEY by default) → the Windows User
environment (a legacy fallback, read once and cached). Configuring the key in
the form is the intended path; the environment variable is the candidate for
scripted deployments.
Every decision row records its judge backend and model; dsh-llm rows also record provider. A log that spans a backend switch stays interpretable.
Settings page
The plugin declares a schemastery Config schema, which the DSH settings
service projects into this profile's configuration form (ctx.settings):
the judge backend, pinned provider/model, matcher, log path, all five
thresholds, the exempt prefixes, and the pre-authorized operations are editable
as a form, validated on write, persisted into the profile patch, and applied by
the Loader without a restart. The same keys keep working in cordis.patch.yml
for scripted setups.
The card appears on the Plugins page's bundle detail, between the description
and the component list.
The profile entry is the single source of truth: there is deliberately no
environment-variable layer, so what the card shows is what runs — no second
truth to drift. (The Python hook twin under hooks/ still reads DECISION_GATE_*
environment variables; that is the hook form's only configuration channel and
a separate deployment path.)
The schema needs @deepseek-ai/schemastery at runtime, resolved from the
plugin's own location like any bare import (Node walks up from the module
file). A normal npm install — including the one dsh plugin add performed
into the profile — provides it.
Install
One command into any profile — the bundle's own patch mounts the gate, so
there is no manual profile edit:
dsh plugin --profile web add E:/workspace/dsh-decision-gate
On the desktop app (whose profile the CLI refuses by design), use the same
source through the in-app Plugins page: 添加插件 → 本地路径 →
E:/workspace/dsh-decision-gate. Both routes link the package into the profile and
select the bundle; the runtime dependencies @deepseek-ai/schemastery and
undici resolve from npm.
Restart DSH (or the desktop app) and the gate is live with the defaults: the
calibrated TypeSafe judge (TYPESAFE_API_KEY — from the card's secret field,
an environment variable, or the Windows User environment), shell tools only,
thresholds as below, .agent-scratch exemption. A region-blocked network (this
one's direct egress gets HTTP 451 from api.typesafe.ai) additionally pins
typesafeProxy in the profile's own patch row — the shipped bundle patch
stays machine-neutral.
Configure
Every key has a default, so the zero-config install works. To override, add a
row that targets decision-gate in the profile's cordis.patch.yml:
- id: decision-gate
config:
judgeProvider: 'dsh-llm'
destructive: 0.4
Because the row is mounted by the bundle, the settings card renders
read-only on a stock install — it still shows every effective value and
judge health, which is the point of the card. A profile-patch row like the
one above is both the configuration channel and what makes the card writable
again.
Uninstall
dsh plugin --profile web remove dsh-decision-gate
One step — the row lives in the package, so removing the bundle removes the
gate. Rolling back to the hook form instead: the hook version is the files
under hooks/, registered through hooks/hooks.json.
Layout
lib/index.js built host half (one runtime dependency: schemastery)
src/index.ts plugin source
src/parity.ts Python↔TypeScript decision parity harness
hooks/ Python twin (specification and regression baseline)
tests/ pytest suite for the Python twin + hook config
docs/decision-gate-setup.md deployment and calibration record
AGENT.md rules for agents working on this repo (not shipped)
examples/ probe payloads used by the live-path tests
The Python twin in hooks/ is the reference implementation the plugin was
ported from; src/parity.ts replays its recorded baseline through the
TypeScript side and asserts the decisions still match. hooks/exempt.py and
the exemption code in src/index.ts mirror each other token for token, held
to the same expectations by tests/test_exempt.py and the offline section of
parity.ts — the two sides cannot drift apart silently.
tests/test_policy.py, tests/test_triage.py and tests/test_live_path.py
(plus examples/) are inherited from the workspace this package was extracted
from: they test the sibling noul-triage feature and import its muiltilab
package. npm run test:py deliberately runs only the gate's own three test
modules; run the full suite from a checkout that has the triage package
importable.
Verify
node tests/functional.mjs # 14 fake-context cases: both backends, fail-open, log
node src/parity.ts # 26 recorded decisions + 30 exemption cases + 3 downgrade
# rules + 12 judge-output parses + 10 config shapes
python -m pytest tests -q # Python twin + hook config
The offline sections of parity need no API key; the recorded-decision replay
needs TYPESAFE_API_KEY.
During the calibration watch period, node tools/observe.mjs prints the
decision distribution, judge error rate, near-miss allows (a dangerous signal
below its threshold), and recent denials for false-deny review.
The deployment and calibration record — install paths, both config channels,
the probe tables, and the periodic threshold reviews — lives in
docs/decision-gate-setup.md.
License
Business Source License 1.1 — see LICENSE. Source is visible and
free to copy, modify, and redistribute, and free for non-production use;
production use requires a commercial license until the Change Date
(2030-10-10), after which the work is available under Apache 2.0.