# 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.6.0** — 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.

<!-- host-compat:begin — generated by scripts/sync-hosts.mjs from scripts/hosts.mjs; DO NOT EDIT -->
- **Supported DSH hosts:** `0.1.0-rc.2` `0.1.0-rc.3` `0.1.0-rc.6` `0.1.0-rc.7` `0.1.0-rc.8` `0.1.1-rc.1` `0.1.1-rc.2` `0.1.2-rc.1` `0.1.5-rc.1` `0.1.5-rc.2` `0.1.5-rc.3` `0.1.7-rc.1` `0.1.7-rc.2` `0.2.0-rc.1` `0.2.0-rc.2` `0.2.1-rc.1` `0.2.1-rc.2` `0.2.1-rc.3` `0.2.1-rc.4` `0.2.1-rc.5` (20 rc releases across lines 0.1.0 / 0.1.1 / 0.1.2 / 0.1.5 / 0.1.7 / 0.2.0 / 0.2.1)
- **Runtime-verified:** `0.2.0-rc.2` — evidence in `.compat-results/`
<!-- host-compat:end -->

## 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.
- **Credential family ladder** — seven credential families (`ssh`, `cloud`, `package`, `gitcreds`,
  `env`, `cli`, `db`), each selectable at `deny` / `ask` (default) / `readonly` / `readwrite`.
  The rung lives in the rules document (`[deny-rule:cred.<family>]` row, section + `enabled`)
  plus the paired `credWriteExemptFamilies` list, written in one settings update — no mirror,
  no half-state. `readwrite` is the only rung that steps a piece of P0 aside (the credential-store
  *write* screen, npm-login style) and asks for an inline confirmation; roots, protected paths,
  `.dsh` and egress stay armed at every rung.
- **Protected-branch push switch** — `allowProtectedBranchPush` (off by default) keeps the P0
  floor on `git push --force/--force-with-lease/--delete` to protected branches; turning it on
  steps that floor aside into the seeded `git.push-force` ask (a person confirms each push) and
  exempts the three flat push keywords from the blacklist.
- **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

### AppContainer sandbox (Windows; beta)

`dsh-perm-gate` can run gate-allowed shell commands inside a **Windows AppContainer** instead of
handing them back unconfined. The container token is created with **zero network capabilities**, so
non-loopback egress is blocked by Windows Firewall (WFP) itself — not by an advisory environment
variable. The container SID is OS-derived from the fixed name `dsh-perm-gate.ac`, so the one-time
`CheckNetIsolation LoopbackExempt` (which lets the container reach `127.0.0.1`) is a per-machine
step, not a per-session one. Egress is therefore possible **only** through the network proxy:

- `sandboxEnabled` (default `false`) — master switch. When on, every **auto-allowed** shell call is
  rewritten into the launcher invocation; a human-approved call is out of scope (the user approved
  it consciously). If the launcher cannot be built or the OS rejects the container launch, the call
  is **denied** (fail-closed) with reason `sandbox: …` — never run unconfined.
- `sandboxMode` — `workspace-write` (default; workspace gets Modify + private temp FullControl,
  system dirs stay readable through the stock ALL APPLICATION PACKAGES ACEs) or `read-only`.
- `sandboxProxy` — **the proxy parameter**: empty (default) follows the builtin filtered proxy's
  actual `127.0.0.1:<port>` when `networkEnabled`; an explicit URL must be loopback-bound (the
  container cannot reach anything else) and destination filtering is that proxy's job.
- `sandboxLoopback` — `exempt` (default; attempt the one-time exemption, warn with the exact
  elevated command if denied) or `off`.

The launcher is a small C# program shipped as source (`assets/appcontainer-launcher.cs`) and
compiled on first use by the .NET Framework compiler every Windows carries (`csc.exe`); the plugin
itself keeps zero native dependencies. Verify a machine end-to-end with:

```
node scripts/smoke-appcontainer.mjs
```

Known boundary: on Windows 11 25H2+ builds where the classic AppContainer creation APIs have been
removed (verified on 26200) and the mxc replacement is not yet exposed (26600+), the launcher fails
closed at the kernel step; the sandbox then denies sandboxed calls with a clear reason instead of
running them unconfined. On Windows 10 / 11 ≤ 24H2 the documented classic path applies.

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 **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` (default `permissive` / `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 (see
> `gatePresets` in 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 one
> `stand-down` event per session/preset transition and the browser shows a sticky **GATE OFF**
> strip above the input. Set `gatePresets: ['*']` 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](./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
    yolo: false                  # unattended: never ask a human, block danger, run the rest
    yoloWorkspaceExempt: false    # YOLO only: stop treating "target unpinnable" as danger (see below)
    rulesFallback: false         # no reviewer available => decide on the rule nets alone
    scriptReview: true           # attach the body of a script the call runs to the review
    credentialPaths: []          # long-tail secret paths, held to the `.ssh` standard
    denyKeywordsExtra: []        # appended blacklist words (never replaces the preset)
    extraContentArgKeys: []      # argument fields that are documents, not operations
    extraOperationArgKeys: []    # argument fields that ARE executed code (wins on conflict)
    safenetPass: true            # the permissive-mech implementation exists (mechanical takeover)
    safenetAsk: false            # the permissive-snet implementation exists (mechanical screen, still asks)
```
Three switches, each owning exactly one permission preset the plugin contributes to the picker —
so a switch changes the behaviour of its own tier and of no other, and switching an implementation
off returns that tier to the ordinary human flow instead of quietly turning it into a different one.

| switch | tier | who settles an ask | asks a person | calls the model |
| --- | --- | --- | --- | --- |
| `permissive` | `permissive` / `permissive-full` | machine screen + grader refine, then a person | yes | per `llmAssist` |
| `safenetAsk` | `permissive-snet` | a person, with the machine screen's doubts listed | **yes** | **never** |
| `safenetPass` | `permissive-mech` | the machine screen alone: named danger blocked + fed back, rest runs | no | **never** |
| `yolo` | `permissive-yolo` | the grader answers in the person's place; risky blocks + feeds back | never | every residual |

`safenetPass` is the reviewed form of `approval: never` (that preset passes every ask with nothing
judging it) and what YOLO degrades to with no reviewer. `yolo` is the model-backed tier by
definition: with no reviewer in seat it still refuses to ask a person, settles on the machine
screen, and records `yolo-allow-no-reviewer` so the history cannot read as if a model had answered.
All five tiers ship in the plugin's own profile patch, so they are in the picker on install —
nothing to hand-edit. Presets outside `gatePresets` never reach any of this: the whole gate
stands down there, so there is no ask to settle.

### `yoloWorkspaceExempt` — the one thing YOLO may stop blocking on

Off by default. It relaxes exactly one signal: **the target could not be pinned down**. A call
whose *content* was never seen is still blocked, so the switch only affects the two shapes where
the act itself is ordinary and only its location is missing — a tool invoked with no arguments at
all, and a script outside the workspace whose body was never read.

| what was missing | switch off | switch on |
| --- | --- | --- |
| no arguments to inspect (e.g. a directory-state tool) | block `opaque-payload` | **run** (reviewed, or mechanically when no reviewer) |
| script outside the workspace, body not read | block `opaque-payload` | **run** |
| encoded payload (`-EncodedCommand`, `FromBase64String`, …) | block | **block** |
| script body attached as unreadable (too large / binary) | block | **block** |
| script body missing, or the target is a directory (nothing will run) | ordinary layers | **unchanged** |
| prose with no parseable program | block | **block** |
| delete / remote write | handled by the ordinary layers | **unchanged** |
| P0, credentials, `irreversible`/`neverClear` | deny | **unchanged** |

Two more preconditions are checked before the signal is skipped: every target the call names must
resolve inside the workspace, **following links** (a prefix-only match like `/work2` is not `/work`,
and a link that points outside is refused; a path that cannot be resolved counts as outside), and
the ordinary write/edit contract must be intact. Only `permissive-yolo` reads the switch; the other
tiers ignore it. Allowed calls are recorded as `yolo-allow-opaque-location-exempt` (or
`…-exempt-no-reviewer`), so an exemption can always be told apart from "the screen found nothing".


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

### Unattended tiers, and how to protect a secret the preset never heard of

Three switches change **who decides**, seven keys change **what is known**. They are all
`volatile`: an edit in the settings card applies to the next tool call, no reload.

- `yolo` (the `permissive-yolo` tier) — never raises a confirmation. A dangerous ask
  (irreversible, unlearnable, an unreadable payload, or a hard-risk verdict from the configured
  reviewer) is blocked and the reason comes back to the agent as a `工具审核意见` message on
  the **steering lane** (`next-step`: beside the refused tool result, at the same turn's
  next step) rather than the user's input queue;
  everything else runs. With no reviewer in seat the tier does not quietly allow — the mechanical
  screen settles it and the event is recorded as `yolo-allow-no-reviewer`, because a tier that
  advertises a model must not report one it never got.
- `rulesFallback` — degraded review. Only when there is **no reviewer** (none configured, or the
  endpoint gave no verdict) the deterministic nets decide instead of parking the call on a popup
  nobody will answer. While a reviewer answers, nothing changes. It governs the ordinary 自动审查
  tiers (`permissive`, `permissive-full`); the three tiers that own an implementation already say
  who answers, so this switch does not degrade them.
- `scriptReview` (on by default) — when a call runs a script, its body is read (bounded, never
  executed) and shown to the model, the human card, **and** the deterministic nets: a delete that
  only appears inside `clean-home.sh` is now graded on code. A fully-read script also stops being an
  unreadable payload, so it leaves the `U` tier. Switch it off to keep file contents on the machine
  — out-of-workspace script runs then go back to "unknowable, ask a human".

The long tail of *what counts as a secret* is yours to extend, and each list only ever adds to the
built-in one — replacing the preset lists is deliberately not offered, because forgetting `content`
or `plan` would silently re-create a bug this gate already fixed:

- `credentialPaths` — path globs (`**` crosses directories, `*`/`?` stay in one segment; a plain
  entry matches a whole path fragment; `~/` expands to your home; a relative pattern also matches at
  any depth). A listed path is held to the same standard as `.ssh`: reading it outside the workspace
  is refused, mutating it through `write`/`edit` is refused. That tier is P0 — not appealable, not
  clearable by a reviewer — which is why it is a setting and not a `rules.yaml` rule: read-only tools
  are auto-allowed *before* the rule chain runs, so a rule cannot see them.
- `denyKeywordsExtra` — words appended to whichever blacklist is live (preset or your own
  `denyKeywords`), so a company CLI subcommand or token prefix gets a deterministic block without
  you copying fifty preset entries into your profile.
- `extraContentArgKeys` / `extraOperationArgKeys` — which argument fields are prose and which are
  operations. The first stops a host tool's `steps`/`notes` field from being read as an act (that
  is how issue #2 happened: an honest plan naming `rm -rf` got refused for describing it). The
  second opens the reverse route for a `workflowYaml` whose body really executes — and it wins if
  both name the same key. Naming a genuine document field there makes "the plan mentions `rm -rf`"
  a refusal reason again, so it is off by default.

Declaring `command` or `file_path` as prose cannot weaken the gate: the executed command and the
mutated path are judged by their own code paths, not by this table (asserted by tests).

### 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
  **keeps the human ask**. The classifier **never denies**: the deny path belongs to the
  deterministic layers alone (P0 hard-deny, the deny-keyword blacklist, explicit `deny:` rules), so
  a misgraded category is always negotiable instead of an unappealable block. Hard categories stay
  distinct from `neutral` in 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 benign `git commit -F …` graded `remote`
  produced an auto-deny with no panel and no grant to retry with.)
- `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.

### 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.

## Credential family ladder (`deny / ask / readonly / readwrite`)

The credential floor used to be one judgement. It is now seven families × four rungs, because
"ask me before reading a `.env`" and "let the agent run `npm login` again" are different needs.

| Rung | file-tool reads / browsing | writes & deletes |
| --- | --- | --- |
| `deny` | blocked by the rule layer | blocked by P0 |
| `ask` (default) | a person confirms each one | blocked by P0 |
| `readonly` | pass silently | blocked by P0 |
| `readwrite` | pass silently | the credential-store **write** clause steps aside (npm-login style) |

- **State lives in two stores the plugin owns**: the family's `[deny-rule:cred.<family>]` row in
the rules document (section + `enabled`) and the paired `credWriteExemptFamilies` list. The
settings card writes both in ONE update through `GET/POST /api/dsh-perm-gate/cred`.
- **Read side** is enforced by a rule-layer pre-query for file-read tools: `decide()` asks the
ruleset first and only a *named* row hit routes the read; a miss keeps the silent auto-allow,
so the popup budget is unchanged. Session/coordination tools are never pre-queried.
- **`readwrite` steps exactly one clause aside** — the credential-store write screen. Root/drive
wipes, protected destructive targets, `.dsh`, egress and the read floor stay armed at every
rung, and the card asks for an inline confirmation before applying it. Custom `credentialPaths`
have no family and are never write-exempt.
- The seven rows ship as preset table **version 1**: upgrades backfill missing rows through the
`presetRulesVersion` watermark and never re-add a row you revoked.

```
SSH (~/.ssh)              deny  ask  [readonly] readwrite
Cloud (.aws/.gcloud/…)    deny  ask   readonly  readwrite
…
```

## 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)