# 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 →
[](https://github.com/SeasonTemple/dsh-decision-gate/actions/workflows/ci.yml)
[](https://github.com/SeasonTemple/dsh-decision-gate/releases)
[](https://seasontemple.github.io/dsh-decision-gate/)
[](LICENSE)
[](https://nodejs.org/)
[English](README.md) · [简体中文](README.zh.md) · [How it works](#how-it-works) · [Judge backends](#judge-backends) · [Install](#install) · [Verify](#verify) · [Changelog](CHANGELOG.md)
---
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:
1. `secret_exposure >= 0.50` → **deny**
2. `destructive >= 0.50` → **ask** when exempt, otherwise **deny**
3. `external_irreversible >= 0.50` → **ask**
4. `read_only >= 0.80` → **allow**
5. `routine_dev >= 0.75` → **allow**
6. 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` | `