# dsh-perm-gate

- [English README](./README.md)
- [中文 README](./README.zh.md)
- [日本語 README](./README.ja.md)
- [한국어 README](./README.ko.md)
- [Installation guide](./INSTALL.md)
- [中文安装指南](./INSTALL.zh.md)
- [日本語インストールガイド](./INSTALL.ja.md)
- [한국어 설치 안내](./INSTALL.ko.md)
- [Changelog](./CHANGELOG.md)
- [日本語 changelog](./CHANGELOG.ja.md)
- [한국어 changelog](./CHANGELOG.ko.md)

> **Compatibility note:** v2.0.0 ships `ja` / `ko` dictionaries, but official DSH exposes
> only `zh` / `en` through `LocaleRuntime` (`LOCALE_IDS = ["zh", "en"]`). On stock DSH,
> selecting `ja` / `ko` fails with `locale "<id>" is not registered`. Use a DSH fork that
> updates `LOCALE_IDS` (locale-settings.ts) and `LOCALES` labels (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](./RELEASING.md)):
>
> | DSH version | Branch | Version | npm tag |
> | --- | --- | --- | --- |
> | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` |
> | 0.1.2-alpha.1+ (incl. 0.1.5-rc.2) | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x` is a range) |
>
> 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.x` install never resolves a `2.x` release and
> vice versa. `engines.dsh` states the same split but DSH never reads it — the ranges
> and dist-tags are what hold an old DSH on `1.x`.
>
> `@deepseek-ai/dsh-client-runtime` was **removed** at `0.1.2-alpha.1` — it did not
> merely move. The `legacy` line still reaches `ctx.slots` through it; `main` gets
> 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 uses `register`, which exists on both lines
> (`installSection` is an addition, not a replacement); (2) `effectivePolicy` is a
> **private** method of the user-approval service on both, so it is read behind a
> `typeof` probe and degrades to “policy unknown” when absent or throwing.

Version **2.1.2** — see the [Changelog](./CHANGELOG.md).

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 with `TTL` + `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 its `callId`; the
  model-visible reason matches the recorded outcome.
- **自动审查 tier** (`permissive`, plus `permissive-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. The
  `permissive-full` variant keeps the identical approval behaviour but drops the built-in file
  sandbox, which otherwise denies the named pipes `git clone` / Cygwin / ConPTY need.
- **Sandbox-escalation auto-answer** (`trustEscalation`) — a sandbox escalation is asked from
  *inside* the shell / pwsh / edit tool body, after `tools/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 by `callId`) is answered here instead.
- **Risk-graded `llmAssist`** — a custom OpenAI-compatible LLM grades each `ask` as
  `safe` / `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](https://github.com/deepseek-ai/deepseek-harness) installation.

```sh
dsh plugin --profile web add dsh-perm-gate
```

Full install, upgrade, migration and troubleshooting steps live in the
[Installation guide](./INSTALL.md) — also available in
[中文](./INSTALL.zh.md) / [日本語](./INSTALL.ja.md) / [한국어](./INSTALL.ko.md).

## Configuration

Add the plugin to `cordis.yml`:

```yaml
- 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

```yaml
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.

```yaml
- 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.

**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](./INSTALL.md#after-a-dsh-upgrade-re-apply-the-composer-glyph-patch).

In `cordis.yml`:

```yaml
- 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 `trustEscalation` is on;
- the request carries a `callId` the gate cleared, with a matching tool name;
- the reason is a recognized escalation naming `workspace-write` or `danger-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 the `classifier` source); no panel is shown.
- `risky` + a **hard category** (`deletion`, `credential`, `remote`, `system`, `bulk`) → the call
  is **auto-denied** without a panel; hard risks are never auto-allowed and never learned.
- `risky:neutral` → with `riskLearning` enabled (Settings card, off by default), each human
  approval that actually executes (settled via the host's `tools/result` event) counts toward
  a `tool|category` key; once the count reaches `riskThreshold` (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 with `llmAssist` off — 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.

## CLI

Dry-run one call against a rules file (no harness needed):

```sh
dsh-perm-gate --rules permissions.yaml --tool bash --args '{"command":"pnpm install"}'
dsh-perm-gate --rules permissions.yaml --list
```

## Development

```sh
npm run typecheck
npm test
npm run build
```

## License

[MIT](./LICENSE)