# Changelog

## Unreleased

## 5.4.4 (2026-11-03)

- **YOLO 工作区豁免开关 `yoloWorkspaceExempt`（默认关）**：`opaque` 拆成两类——`consequence`（执行内容看不见：编码载荷、正文读不到的脚本、prose）与 `location`（动作本身可见，只是落点判不清：无参数工具、工作区外未读到正文的脚本）。开关只在 `permissive-yolo` 一档上跳过 `location` 那一条，且要求目标全部落在 workspace 内、非删除/非远端写、非不可逆/不可沉淀；`consequence` 与 P0/凭据/`irreversible`/`neverClear` 一律不变。工作区边界的判定在运行时另走 `pathsRealInsideWorkspace`（`node:fs` realpath，Windows 折叠大小写），符号链接/目录联接指向外部、或路径无法解析时一律不放行；`ask-context` 仍保持无依赖的词法判定供浏览器半复用。无审查模型时被豁免调用直接放行，事件标 `yolo-allow-opaque-location-exempt(-no-reviewer)`，审计 reason 带 `[exempt:opaque-location]`，与「机械网没发现」可区分。其余四档行为零变化，关机即回到 5.4.3 行为。
- **看不见的脚本体现在真的算 opaque**：`too-large` / `binary` / `unreadable` 的脚本正文一律使调用进入 U 档（此前只有工作区外的脚本会），与 `ask-context` 里「one unreadable file keeps the whole call opaque」的注释一致；`missing` 与 `not-a-file` 故意排除——那里根本不会执行任何东西，放宽才不会被用户当成噪音忽略。

## 5.4.3 (2026-11-03)

- **#6 fail-open 修复**：宿主把 volatile 字段以**引用**交给 `apply`，此前 `...config` 把引用原样展开进运行时选项；引用对象既不等于 `deny` 也不等于 `ask`，运行时两条分支都落空，未命中任何规则的调用直接放过——即使配的是 ask / 默认审查。现在构造前先 `resolveVolatileConfig` 解引用，`coerceRuleAction` 把非三值一律 fail-closed 成 `ask`（规则文档路径同样过一遍），`defaultAction` / `caseInsensitivePaths` 改由 live 钩子读取并纳入热更新 key，改设置后 `reload()` 生效（此前只有首轮播种，改值不生效）。
- **#7 假 SAFE 修复**：host 审查只采纳 `text-delta`，`reasoning-delta`（模型内心独白）不再拼进待解析文本；`max-tokens` / `length` / `content-filter` 等截断收尾判为失败而不是短答案；`parseRiskVerdict` 删掉裸 `safe` 关键字兜底——“not safe”、半截 JSON、纯文本 `SAFE` 一律 unresolved，留在人工确认上。
- **#8 host-review 集成修复**：(1) `reasoningEffort` 不再硬编码 `'off'`，改由新 volatile 配置 `classifierEffort` 控制（设置卡新增「推理强度」，留空＝不发送、交给宿主模型配置），被宿主拒绝时可选 low/off；(2) `POST /api/dsh-perm-gate/health` 曾用 `{ ...result, ok: true }` 把检查自己给出的 `ok:false` 覆盖成健康，现改为以检查结果为准；(3) host 模式清空 Model 真正回落到会话模型组——`classifierModel` 不再带 schema 默认值，空值不再被当成显式覆盖（custom 端点模式仍在请求时兜底 `deepseek-chat`）。

## 5.4.2 (2026-11-02)

- **拦截意见不再冒充用户消息**：被拦后给主模型的 `工具审核意见` 曾以 `mode:'queue'` 投到 next-turn——那是用户的输入队列，意见投进去会在 input-traffic 等待区里像用户自己排的一条，并与待发消息抢队列，且要等整个回合结束才送达（模型多反刍一轮）。现在交由 `buildBlockNoteSender` 投递：首选 `agent.inject()`（next-step 带内、不唤醒 driver，贴着被拒的 tool 结果在下一个 pre-step 送达）并标上自有 `source.kind='perm-gate'`，因为宿主的语义里只有 `source.kind==='user'`（带 `rpcId`）才是“人说的话”，其余一律按注入上下文渲染；没有活 agent 时才回退到 steer 车道（`session.prompt mode:'steer'`），绝不静默降级成 `followup`。yolo / 机械直通 / 降级审查 / 确定性 deny 四条拦截路径统一如此；评审页「撤销此改动」仍走 next-turn（那是人想发的一句话）。
- 配套：`dsh-input-traffic` 同步把「插话中」限定为真人插话（`source.kind === 'user'`），宿主自有通知（如 `user-approval` 的审批策略变更）与第三方意见不再占位等待区；没带 `source` 的旧行仍照常显示，不会误伤真队列。

## 5.4.0 (2026-10-30)

- 凭据类型阶梯：七个内置族（ssh / cloud / package / gitcreds / env / cli / db），每类四档 deny / ask（默认）/ readonly / readwrite。档位状态就住在规则文档本身——每类一条 `[deny-rule:cred.<family>]` 行（分区 + enabled）加成对的 volatile `credWriteExemptFamilies` 清单；设置卡经 `GET/POST /api/dsh-perm-gate/cred` 一条 update 成对读写，无本地镜像、无半态。readwrite 需卡内二次确认，且只退让 P0 的一条判据（凭据 store 写）；根盘、受保护路径、.dsh、外发在任何档位不让；自定义 credentialPaths 永不参与写豁免。
- 引擎：`decide()` 对 READ 工具先问规则层（被具名 ask/deny 命中的文件读才会被路由；未命中保持原静默自动放行，弹窗预算不变）；INTERNAL 协调工具永不预询。新增纯模块 `src/cred-families.ts`（族 glob + `familyOfPath`）。
- 播种：预设表 52→59 行；新 volatile 水位键 `presetRulesVersion`——升级后只补新增行（按 id 幂等，用户撤销的行永不复活）。
- 保护分支强推地板开关 `allowProtectedBranchPush`（volatile，默认关=地板立；开则地板让位、落入预设 git.push-force 的逐次 ask，并同步豁免黑名单三条扁平 push 词）。
- 预设表新增 `[deny-rule:rsync.delete]`（rsync --delete 默认 deny，附 dry-run 建议）。

5.3.3: host enumeration addendum for DSH 0.2.1-rc.3 (declaration-only bump; contract plane zero diff).

## 5.3.2 (2026-10-18)

- 说明澄清：明确申明本插件的**执行前守卫**——在工具调用运行前拦截破坏性 Git 与文件系统命令，以及对 SSH 密钥、.env 等机密文件的访问（P0 硬拒绝 + 确定性机械审查器，无审查模型在场时仍生效）；投稿描述同步申持该能力并建议使用带模型审查的模式
- 档位命名/描述澄清：裸名「自动审查」标明为「自动审查（dsh沙箱中）」以区别于高权限（无沙箱）档；机械过滤标明为**危险/不确定才人工确认**（仍接回沙箱组，作用域内安全操作由 trustAutoAllow 静默放行、不弹确认）、机械直通标明为**检查通过直接放行**（cordis.patch.yml 预设名/描述 + 设置卡 askHint/passHint 中英同步）
- hosts: 追加 0.2.1-rc.2 声明面枚举（34/34 @deepseek-ai/* 宿主 peer 逐包 npm 实测 rc.2 在版；本插件不触 ctx.web provider 与 conversation.input.model.label 槽，代码零改动，纯元数据发版；农场 rc.2 格挂账待命）

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [5.3.1] - 2026-10-17

### Added

- **Host support: DSH `0.2.1-rc.1`.** The supported-host enumeration (`package.json` engines.dsh,
  `dsh.plugin.json`, every `@deepseek-ai/dsh*` peer and the README managed blocks) now lists
  `0.2.1-rc.1` alongside the fifteen rc releases up the 0.2.0 line — exact per-rc enumeration,
  no ranges. Behaviour is unchanged: this is a declaration-surface release so the host gate
  (which since 0.2.1-alpha.4 reads engines.dsh alone) accepts the plugin on a 0.2.1-rc.1 host.

### Changed

- Pinned `publishConfig.registry` to `https://registry.npmjs.org` (release hygiene: a publish can
  no longer drift to a mirror registry).

## [5.3.0] - 2026-10-17

### Added

- **YOLO tier** (`yolo`, owning the `permissive-yolo` preset): never raises a human
  confirmation. A dangerous ask — irreversible, unlearnable, an unreadable payload, or a
  hard-risk verdict from the configured reviewer — is blocked and the reason is fed back to the
  main agent as a `工具审核意见` user message; everything else runs. With no reviewer in seat it
  does **not** quietly allow: the mechanical screen settles it and the event is labelled (see the
  `yolo-allow-no-reviewer` entry below).
- **Degraded review** (`rulesFallback`): when the reviewer is not configured or returns no
  verdict, the deterministic nets decide instead of parking the call on a confirmation nobody
  will answer. Unchanged while a reviewer answers; it applies to the ordinary 自动审查 tiers
  (`permissive`, `permissive-full`) — the three tiers that own an implementation already say who
  answers, so this switch does not degrade them.
- **Script review** (`scriptReview`, on by default): the body of every script a call runs is read
  (bounded, never executed) and given to the grader, the human card and the deterministic nets —
  a delete that only shows up inside the file is graded on its code instead of its filename. A
  fully-read script also stops being an unreadable payload; switching this off keeps file content
  on the machine at the cost of sending such runs back to the `U` (unknown-target) tier.
- **User-declared protection**, all additive and all hot-applied: `credentialPaths` (path globs
  held to the same standard as `.ssh` — reading one outside the workspace, or mutating one with a
  file tool, is refused), `denyKeywordsExtra` (appends to the blacklist; the preset list stays
  un-replaceable), and `extraContentArgKeys` / `extraOperationArgKeys` (which argument fields are
  prose and which are operations, on top of the built-in lists).
- **Three unattended implementations, one switch and one permission preset each**: `safenetAsk`
  (`permissive-snet`) screens with the deterministic nets and **still asks a person**, calling no
  grader at all — the shape the gate takes when no reviewer LLM is connected, or when the tokens
  are not worth spending and a human answer beats an auto-decision. `safenetPass`
  (`permissive-mech`) lets the same screen **take over the ask**: named danger is blocked and fed
  back to the main agent, everything it cannot condemn runs, with no human prompt and no model
  call — the reviewed form of `approval: never`. `yolo` (`permissive-yolo`) is the model-backed
  tier: the grader answers in the person's place and it never escalates. The mapping is the point:
  a switch changes the behaviour of the tier named beside it and of no other, and switching an
  implementation off returns that tier to the ordinary human flow rather than quietly turning it
  into a different one. All five tiers ship in **the plugin's own** profile patch, so they are in
  the permission picker on install with nothing to hand-edit; `permissive` itself is untouched.
  (A per-preset `<preset>=<mode>` table existed for two revisions and is retired — it competed with
  these switches, and two controls for one posture means one of them lies.)
- **YOLO no longer claims a reviewer it did not get.** With no grader configured, or one that
  returns `unresolved`, the tier still refuses to ask a person and settles on the mechanical
  screen — but the event is recorded as `yolo-allow-no-reviewer` and the reason says so, because
  an unlabelled fallback made YOLO and the mechanical tier indistinguishable in the approvals
  history exactly when the model was missing.
- **One mechanical floor for every tier**: the deterministic judge (irreversible / unlearnable /
  unreadable) was hand-copied in three places — YOLO, the degraded gate and the new passthrough
  tier. It is now a single `mechanicalJudge`, so a tier can change only *who settles the residual*,
  never *what counts as danger*. Pinned by tests: the same call is blocked under YOLO and under
  passthrough alike, and neither spends a model call on it.
- The settings card's booleans are now **optimistic pill switches** (36×20 capsule, state keyed off
  `aria-checked`, theme tokens with fallbacks so light and dark both read correctly), and each
  unattended row prints the preset it governs. They flip on click and hand the visual back to the
  store when the write lands, is rejected, or is never reported.
- The approvals history labels the decision paths that previously showed raw tags: `yolo-allow` /
  `yolo-block`, `rules-fallback-*`, `safety-net-passthrough-*`, plus four older ones that also
  reached the feed untranslated (`cleanup-safe`, `escalation-auto`, `learned-granted`, `network-ask`).
- A deterministic net for deletions aimed at the profile or a protected Windows root **through a
  variable or a glob** (`$HOME`, `~/.*`, `$env:USERPROFILE\.*`, `%WINDIR%`, `$PSHOME` …): the
  profile root is refused outright, other protected roots become a permanent confirmation that no
  answer can be learned away. Measured: no grading prompt — not one primed with read-only-variable
  knowledge — held this on a 9B reviewer, which is why it lives in code.

### Fixed

- Issue #2: a document tool's prose is no longer read as an operation. One shared rule
  (`src/scan-text.ts`) picks readable keys by tool class — a read/internal/write tool exposes only
  its command, url, path and query fields, while an unfamiliar tool name is still treated as an
  executor. `exit_plan_mode` can now describe a step it intends to ask about; previously the plan
  text hit the destructive blacklist and the model learned to reword plans to get past the gate.
- The same report's aside: newlines and glued `;` / `|` / `&&` / `||` outside quotes are now
  statement separators for `decomposeShellCommand`, so the second statement of a multi-line
  command is finally seen as a program a rule can match. This was the same root as the cross-line
  delete leak; a lone `&` is left alone so `2>&1` and background jobs keep their shape.
- Issue #3: an escalation the default safety screen produced reports itself
  (`write target outside the workspace (…)` / `command carries a file-write or redirect pattern`)
  instead of impersonating "no rule matched", and the reviewer's own sentence now reaches the
  confirmation and the block wording — flattened, bracket-stripped, redacted and capped, so a
  model cannot forge a `[deny-rule:…]` tag into the audit trail.
- Issue #4: the write-pattern pre-screen no longer reads `=>` or `->` as a redirect into a file.
- Credential stores mutated through `write`/`edit` (`~/.ssh/authorized_keys`, `.git-credentials`,
  `.npmrc`, cloud credential files …) are refused at P0. Only deleting them was covered, so an
  auto-allowed write could install an access backdoor.
- The public half of a keypair (`id_ed25519.pub`) is no longer caught by the whole-arguments
  credential screen, which had made a hand-carried key restore impossible to appeal.
- `deny-keyword` audit wording drops "preset" for entries the user appended.

### Known gap

- `pathArgument` reads `file_path` / `path` / `cwd` / `workdir`; plural fields (`paths`, `files`,
  `urls`) are not held to the credential-path standard.
- A tool that executes a document it was given (`workflow`, `skill`, `ralph`) is not read from that
  body by the flattened screen; the work it delegates is gated as its own tool call.

## [5.2.0] - 2026-10-14

### Added

- `src/preset-rules.ts`: 51 `[deny-rule:*]` entries ported from cc-safety-net (MIT; attribution in
  `THIRD_PARTY_NOTICES.md`). `mergePresetIntoRaw()` appends to the tail of `deny`/`ask`, is
  idempotent per id, and never re-adds a row the user revoked. New volatile `presetRulesSeeded`,
  written once on activation.
- `argv.pipeline` is evaluated per simple command as well as over the joined string — the preset's
  hit rate depends on it — and a rule verdict is attributed by its `deny-rule:<id>`.

### Fixed

- The seed ran inside `apply`'s synchronous body, where the settings service is not mounted yet, so
  it silently never happened on any host. It now runs in the inject callback, after each host
  generation has registered the namespace (on 0.1.5 a write before `installSection` throws).
- Rules and the idempotency flag were two separate `update` calls; on rc.1+ the second throws
  `Plugin entry "dsh-perm-gate" is no longer configurable`, so the flag never landed and a full
  user revocation would be re-seeded forever. Now one update, with the flag written only after the
  rules succeeded.
- `rawEntry()` emitted the *compiled* `ParamCondition[]` for the `params` dimension, so the seeded
  document failed this plugin's own reader on the next boot
  (`permissions.deny[22].params.params must be a mapping`) and perm-gate stopped activating.
  Reverted to a mapping, restoring the `!glob` negation prefix.
- Dropped the fallback that seeded into the plugin's own `perm-gate/rules.yml`: a hand-maintained
  rule source must not get 51 injected entries mixed into it, and the branch only ran on legacy
  hosts, so the two host generations behaved differently. No settings service now means no seeding,
  reported loudly instead of degrading into a file write.
- Added the gate that would have caught the third item:
  `test/preset-rules-roundtrip.spec.ts` feeds the merged document back through the reader and the
  compiler, with a negative control (reverting the fix fails 3 of 5 cases).

## [5.1.2] - 2026-10-12

### Changed

- **正式发行版**：将 `compat-legacy` 线上已验证的 compat.2 … compat.15 全部工作（B1 脱敏原语 / B5 P0 单一不可逆真源 / B6 旁路解码 / B7-B8 grader 规则 / B2-B4+B6 接线 / compat.11 版本修正 / compat.12 `reg delete` 子键修复 / compat.13-15 弹窗三段人话 + 审批回溯 + 提示随 UI 语言切换）毕业为干净版 `5.1.2`，
  从 `main` 线发布并将 `latest` 从 `5.1.0` 指向 `5.1.2`。`publishConfig.tag` 回为 `latest`。
  目标宿主范围与旧 `latest`(5.1.0) 一致（engines 0.1.0-rc.2 … 0.2.0-rc.2），仅同主线的预发布线仍留在 `compat` 标签。

## [5.1.2-compat.15] - 2026-10-12

### Changed

- **提示文字跟随 UI 语言切换（通知条 + 审批记录）**：上一版把服务器生成的英文 `humanBrief` 原文存进事件，
  回看时不管 UI 是什么语言都显示英文。现客户端改从事件的 `doubtKinds`（机器键，早已落盘）经 `t('doubt.xxx')`
  渲染——`locales.ts` 新增 10 条 `doubt.*` × 9 语，通知条与审批记录列表两处渲染点本地化；
  旧事件无 `doubtKinds` 时回退 `humanBrief ?? justification ?? reason`。弹窗那半（宿主 `.headline` 取 `displayReason.{zh,en}`）不变。

## [5.1.2-compat.14] - 2026-10-12

### Changed

- **命令不再夹在文案里**。宿主审批面板的排版是固定的：`.headline`（放 `displayReason`，一个普通 div，**不保留换行**）
  + `.command`（等宽代码块，由 ui-chat 注册的 `conversation.approval.detail` 填充）。
  上一版把命令塞进 `displayReason` 的“影响面”一句，结果换行被吞掉、命令埋在句子中间读不出来。
  现 `humanBrief()` 只输出三段文案（疑点 / 影响面 / 代价），命令走它自己的通道：
  弹窗交给宿主的代码块，审批记录则落在新的事件字段 `humanCommand`（脱敏、上限 400 字），
  面板用等宽 + `white-space: pre-wrap` 单独成块渲染。

## [5.1.2-compat.13] - 2026-10-11

### Added

- **弹窗不再只给内部代号**：`humanBrief()` 把一次 ask 翻成三段人话——**疑点**（为什么没结论：无规则覆盖 / 载荷读不懂 / 规则判不可逆 / 模型只能升级不能放行）、**影响面**（目标类型 + 可逆性 + 看到哪些文件 + 脱敏命令）、**两个按钮各花什么钱**（按 R/C/I/U 分档，I 档写明“无法撤回”）。
  进 `displayReason: { zh, en }`，宿主按 UI 语言取，其余七语回退 `en`；审计 `reason` 保持英文短标签不变。
- **审批记录可回溯**：同一份 brief 随人工终态事件落盘（`humanBrief` + `doubtKinds`），面板优先渲染它。之前弹窗里的解释只存在几秒钟，回看时只剩 `no rule matched; default action`。

### Fixed

- **只读命令被标成 `target=write`**：只读判定是“读动词 + 全局无管道”，于是 `Get-ChildItem -Force | Select-Object Name`（PowerShell 会话里最常见的形状）在卡片上被说成会写。现逐段判定：首段是读动词、后面全是重排过滤器（`Select-Object`/`Where-Object`/`Out-String`/`Format-Table` …）、且无重定向，才算只读；管道进写入器/网络工具/`Stop-Process` 仍不算。
- **工具名白名单是个绕过面**：P0 与规则链都靠 `SHELL_TOOLS` 认“这是不是一条命令”，而模型已经会打出 `pash` 这种名字（实测日志可见）——不认识的 runner 带着 `diskpart /s x.txt` 就跳过了所有命令级检查。现按**形状**判定（参数里有 `command`/`cmd`/`script` 串），并保留已知非执行器（`read`/`write`/内部工具）的例外，否则一份引用了命令的文档会被当成执行了它。
  名单本身（`READ_TOOLS`/`INTERNAL_TOOLS`/`WRITE_TOOLS`/`SHELL_TOOLS`）集中到 `src/tool-names.ts`，之前“这是一条被执行的命令吗”在代码里有两个答案。

### Known gap

- 面板端的 `doubtKinds` 目前直接显示英文 brief（审计语言）；按 9 语渲染疑点文案需给 `locales.ts` 补 10 个键 × 9 语，已记为后续页。

## [5.1.2-compat.12] - 2026-10-11

### Fixed

- **`reg delete` 只匹配了 hive 根，没匹配真实在用的子键形**：`REGISTRY_DESTROY` 在 `system` 后留了个 `\b`，
  而解析器已把 `HKLM\SYSTEM\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy` 剥成 `HKLMSYSTEMCurrentControlSet…`——
  于是**所有带子键的删除全部绕过 P0**（只落到 ask），而无人在跑的裸 hive 根反而被拦。
  现去掉尾部边界；新预例：`reg delete HKLM\SYSTEM\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy /f`、
  `reg delete "HKLM\SYSTEM\MountedSoftware" /f` 均为 deny；
  `reg delete HKCU\Software\…`、`reg delete HKLM\SOFTWARE\…`（非 hive）仍放行。
  发现的途径：给真宿主组装可粘贴测试集时，把每一行先过一遍已装副本的 dry-run（`scripts/gate-dryrun-check.mjs`），29 行里就它一条 DIFF。

## [5.1.2-compat.11] - 2026-10-11

### Added

- **`src/redact.ts`**：有界且幂等的脱敏原语（`[redacted:<len>:<kind>]`、文档体→字节数+哈希）。此前 `classifier.ts` 声称“密钥已在上游剥离”，实际并未剥离 —— grader 载荷是整包 args 原文外送第三方端点的。
- **`src/ask-context.ts`**：一次构造、三个投影（grader 载荷 / 人侧卡片 / 事件归因）。档位三字段（preset/sandbox/approval）只进弹窗与事件流，**绝不进 grader prompt**（实测喂给模型会让它顺着规则层答而不是自己判，打扰 67.1%→87.8%）。
- **`src/irreversible.ts`**：不可逆性的单一真源，分三层 —— P0 deny / never-clear ask（模型不能洗白、确认不能沉淀）/ 可学习 ask。所有函数只返回 `string | undefined`，结构上无法产生 allow。
- **`src/command-normalize.ts`**：base64（UTF-16LE/UTF-8 双读）/hex/变量展开的旁路解码，四条护栏：不回写、限长限深限变体、超预算丢弃部分成果、自家 `--exec-b64` wrapper 认出但不豁免解码。
- **`src/grader-rules.ts`**：classifier 与 risk 共享的唯一判据真源（分开成文件以避开 ESM 循环引用）；`classifyWithLLM` 标 `@deprecated`，其 deny 永不进决策路径。
- 弹窗 `displayReason`（宿主正式字段，不进审计）：为何在问 / 脱敏命令 / 文件清单+真实总数 / `tier=target=payload=opaque never-learn` 标 / 两个按钮的代价。
- `PENDING_CAP` FIFO 淘汰可观测：`evictions` 计数 + 事件告警 + `pendingAskStats()`。

### Changed

- `unknown-target` 成为 risk 协议的**硬类目**（永不 auto-allow、永不学习），且刻意不进任何 deny 词表：“看不见”不是“确定坏”。
- 判定层按程序名位置匹配，不做自由文本词匹配；`unresolved` 用 `verdict` 标签区分（不新增 `kind`，因为 `presentation()` 的 default 分支会把未知 kind 渲染成绿色 ALLOW）。
- 提示词版本化：`v7-terse-budget`。延迟门槛按服务端 `timings.prompt_ms` 量（墙钟在共享端点上会被排队淹没）：v5 +36.0ms → v6 +1.9ms → v7 −0.1ms 且 token 持平 714。

### Fixed

- `decomposeShellCommand` 会剥掉裸参数里的反斜杠，导致所有要求 `\` 分隔符的 Windows 规则（`HKLM\SYSTEM`、`C:\Windows\System32`）在主判定路径上默默落空 —— 现同时匹配“去分隔符”形态（要求驱动号前缀，避免把仓内目录名当系统根）。
- 同一解析器不拆 `;` 链且剥引号，致使 `Select-String -Pattern "vssadmin|cipher|diskpart"` 被当成执行 `diskpart` 并不可申诉地拒绝 —— 现改用解析器给出的权威程序名、仅在 arg 边界取链、分段器改为引号感知；链式 wipe 覆盖不回退。
- `containsCredentialMaterial` 对参数里任意 `Bearer <token>` 与只读探测（`Test-Path ~/.ssh/id_rsa`）直接 P0 硬拒 —— 两条都是误拦（正当编码反例集 1/20 → 0/20），且带密钥的命令根本到不了弹窗；同一物料一旦移动（`scp`/`Copy-Item`/管道入 `curl`）仍拒。
- 流水拆段使“密件 + 外发通道”分属两段、per-command deny 看不到成对关系 —— 现由整调用视图的 `tier=I` 否决兑住（只否决模型，不产生 deny）。
- `dsh.plugin.json` 版本号与 `package.json` 差一个发布号（宿主读的是前者）。
- `client-settings-seat` 测试仍断言 compat.10 已退役的 `shell.overlay` 席位（基线红），现跟随退役决定修正。

## [5.1.2-compat.10] - 2026-10-08

### Removed

- 退役 `shell.overlay` 悬浮齿轮：设置面在所有宿主线上都由 `settings.section` 渲染（+ bridge describe/mutate），固定角落的齿轮全机冗余。

## [5.1.2-compat.9] - 2026-10-08

### Changed

- **回退家族 tab 收拢（用户定案：自动审查门不在 tab 化范围内）**：顶级
  `settings.section`（侧栏「自动审查门」独立节，order 30）恢复为唯一设置导航
  入口；同日上午加入的 `dsh-family.tab`（order 45）撤除。
- `settings.plugins.tab`（插件页 tab，0.1.0 实测与独立节双渲染）与
  `settings.plugin.item`（≤0.1.5 派发卡）维持退役——与独立节重复；
  `plugins.bundle.config` 详情卡与 `shell.overlay` 浮窗齿轮不变。
- 席位契约测试（test/client-settings-seat.spec.ts）改钉单节形态：
  settings.section 恰一次（order 30 / PermissiveCard），三个退役席位断言缺席。

## [5.1.0] - 2026-10-05

### Changed

- Unified the 15-rc enumeration line: the three host sub-packages (`client-locale`,
  `client-ui-renderer`, `client-ui-settings`) enumerate their peers in full, the content equals the
  verification-farm-confirmed `4.3.0-beta.2` tree, and the version supersedes the compat line's
  `5.0.4`.

## [2.6.0] - 2026-09-17

### Added

- **Rule test (dry-run) in the settings card.** A new panel evaluates one would-be tool call
  against the ruleset the gate currently has loaded and shows the verdict, the matched rule (index
  and action), the dimensions that rule constrains, and the reason. Written for the question the
  YAML cannot answer by inspection: *what will this actually do?* — `args` is an OR over tokens, an
  empty dimension constrains nothing, and `command` matches the decomposed command **word** only, so
  a plausible-looking rule can be dead on arrival.

  The report keeps two answers apart on purpose. **`verdict`** is the policy result of the whole
  chain (P0 hard-deny → deny-keyword → P1 grant → P2 rules → P4 ask → permissive).
  **`ruleLayer`** is what the `permissions` chain decides on its own, and only that layer can name a
  rule index: a P0 hard-deny or a preset deny-keyword fires *before* it and leaves no rule behind, so
  the panel says so instead of attributing an unrelated rule. `matchedDimensions` likewise lists the
  dimensions the matched rule *constrains* rather than the one that "caused" the match — dimensions
  are ANDed, so a single cause cannot honestly be named. A `ruleCount` of 0 is surfaced as "0 rules
  loaded, check the rulesFile path" rather than as "nothing matched".

- **`POST /api/dsh-perm-gate/dry-run`** — the panel's endpoint. **Read-only by construction**: it
  has no write form, unknown body keys are dropped rather than forwarded, and it never touches
  rules, grants, learning state or the settings namespace. Testing a rule must not be able to change
  it. It evaluates against the **live** runtime rather than a fresh one, so the panel tests the rules
  in force — a fresh runtime would re-resolve the chain from a different root and could silently
  answer about a different file.

- **`src/dry-run.ts`** — `runDryRun` / `createDryRunRuntime`, the shared evaluator for the CLI and
  the route, plus `PermGateRuntime.explainRules` (read-only rule-layer report) and
  `PermGateRuntime.explainCall` (pure policy evaluation). `src/cli.ts` is now a thin wrapper over it;
  its output is unchanged across nine replayed invocations, verified byte for byte (including the
  `--list`, no-tool, bad-JSON and write-path cases).

### Fixed

- **The rule-test panel reported `allow` for calls the rules send to a human.** The first version
  evaluated through the host-facing path, which applies the session layers — the preset stand-down
  and the `approval: never` ask degradation. A session-less dry-run has no session, so the policy
  reader answered "never", every `ask` degraded to a passthrough, and the panel showed **allow** for
  exactly the rules someone opens it to review. Measured on the live host: `shell ls -la` showed
  `allow` while the rule layer on the same card said `ask`.

  `runDryRun` now reports the **pure policy** verdict from `explainCall`: the same
  P0 → deny-keyword → P1 → P2 → P4 chain with the session-state layers excluded rather than guessed,
  and with `reason` never collapsed to a bare `(default/passthrough)`. The host-facing view remains
  available under `input.hostView`; the CLI asks for it, so its historic output stays byte for byte
  identical (re-verified: 9/9 cases, SHA256 per case).

- **The "read-only" route was not read-only.** It ran the host-facing decision path against the live
  runtime, which appends audit entries and records decision events — opening the rule-test panel
  wrote to the live decision feed. The pure path appends nothing, and `test/dry-run.spec.ts` now
  asserts that a call the host path *would* record leaves the audit mirror at zero.

## [2.5.0] - 2026-09-17

### Added

- **`branch` rule dimension** — git branch / remote / protected-branch matching, so a rule can
  finally say "no push to a protected branch" without catching unrelated commands. The decisive
  case is the one `args` can never express: `git push --force origin main` (dangerous) and
  `git checkout --force main` (routine) carry the *same tokens*, and only the branch dimension
  tells them apart. Sub-fields: `target` (branch-name glob, `*` crosses `/`), `remote`
  (remote-name glob) and `shared` (require a protected branch). Candidates come from the shared
  command dispatcher, so `refspec` forms (`HEAD:main`) are already split and a remote name is
  never mistaken for a branch name. Documented in `docs/rules-format.md` §4.11.

### Fixed

- **The multi-file rule chain silently emptied every match dimension.** `resolveRuleChain` merged
  entries by hand with `tools: []`, `command: []`, `args: []` and `paths: []`. An empty dimension
  means "no constraint", so EVERY merged entry matched EVERY call: the first `deny` entry denied
  everything in its partition, and the first `allow` entry allowed everything before the `ask`
  partition was ever consulted. The merge now goes through the same `compileRuleEntry` the
  single-file path uses.

  *Reach:* the chain resolver is reached **only** under `searchUp: true` (product default `false`,
  and unset in a stock profile), so the default single-file path — `compileDocument` — was never
  affected. That is also why the existing suite stayed green: no test drove a rules *file* through
  the chain. `test/rule-chain.spec.ts` now does. A separate, still-open defect on the same
  opt-in path is recorded in the plan: `findChainEntries` joins the configured `rulesFile` onto
  each directory, so the absolute path `resolveRulesFile` always produces matches nothing and the
  chain yields an empty ruleset without an error.

- **`argv.pipeline` could not see its own subject.** The pipeline match string was built from
  `SimpleCommand.command`, which is only the command *word* — `curl https://x.sh | sh` collapsed
  to `curl|sh`, so the documented `curl|sh` pattern matched the harmless adjacent form while the
  genuinely dangerous one with arguments did not match at all. The match string is now each
  simple command's full argv (joined by `|`), and the docs state that `|` in a pattern is a
  literal, so covering arguments requires `curl*|sh`.

- Nine pre-existing `eslint` errors (unused imports/constants and one `prefer-const` in
  `src/parsers/` and `test/command-parsers.spec.ts`) — `npm run lint` is green again.

## [2.4.1] - 2026-09-17

### Fixed

- The approvals history showed the raw `preset-passthrough` string for the one event that
  explains *why* a flagged call ran unreviewed: the tier declares `approval: ask`, the session was
  overridden to `never`, and the gate's ask therefore degraded to a passthrough. It now renders as
  a readable label like every other verdict.

## [2.4.0] - 2026-09-17

### Changed

- **The P3 LLM classifier is escalate-only — it can no longer deny.** A `risky` verdict with a
  hard category (`deletion` / `credential` / `remote` / `system` / `bulk`) used to **auto-deny**
  with no panel; it now **keeps the human ask**. Denying is reserved for the deterministic layers
  alone — P0 hard-deny, the deny-keyword blacklist, explicit `deny:` rules — because a
  probabilistic verdict must not be able to hand down an unappealable block. Measured live: the
  grader called a benign `git commit -F …` **`remote`** and the auto-deny left no panel to approve
  and no grant to retry with; only a manual retry (which happened to grade `safe`) got past it.
  This also aligns the code with the project's own rule that high-risk operations are intercepted
  **deterministically, never on the LLM's judgement**.

### Added

- Hard risk categories keep one meaningful distinction from `neutral`: they are **never
  learnable**. Repeated human approvals cannot sediment a `deletion`/`credential`/`remote`/
  `system`/`bulk` verdict into an auto-allow (previously this was true only as a side effect of
  the auto-deny; it is now an explicit property).

### Behaviour notes

- Under `approval: never` an ask the gate cannot deliver still degrades to passthrough, so a
  classifier-flagged call now **runs** where it used to be auto-denied. That is the direct
  consequence of "deny only what is deterministically dangerous, negotiate everything else": a
  negotiation needs a human, and `never` means there is none. Run the gate in a tier whose
  `approval` is `ask` for the flags to reach you.

## [2.3.0] - 2026-09-17

### Added

- **A stand-down is no longer silent.** When the session's permission preset is outside
  `gatePresets`, the gate used to stand down and record nothing — so the tool call looked
  exactly like one the gate had inspected and allowed. It now records **one** `stand-down`
  notice per (session, preset) transition (never per call), naming the preset, the scope and
  the fact that P0 hard-deny is inactive. The browser renders it as a sticky **GATE OFF**
  strip above the input, and the approvals history shows a `Gate off` tag.
- The 自动审查 settings card states the gate's own preset scope (`gatePresets`, default
  `permissive` / `permissive-full`) and what happens outside it, so the scope is visible
  where the tier is configured.

### Changed

- **P0's documented positioning is scoped, not global.** P0 hard-deny is monotonic and
  non-negotiable *within the gate's preset scope*; across presets the gate stands down
  entirely — P0 included — because the selected tier's own policy owns that session. The
  code always behaved this way; `AGENTS.md` and the four READMEs claimed otherwise, which
  made `danger-full-access` read as "P0 still applies". Set `gatePresets: ['*']` to make P0
  global again.

### Fixed

- `DEFAULT_GATE_PRESETS` / `resolveGatePresets` moved from `config.ts` (which imports
  schemastery) into the dependency-free `preset.ts`, so the browser half can render the scope
  without pulling a node-only dependency into the client bundle. `config.ts` re-exports both,
  so existing imports are unchanged.

## [2.2.0] - 2026-09-17

### Fixed

- **P0 hard-deny skipped every shell tool but four.** `hardDenyReason` gated its shell
  inspection on a locally defined regex, `/^(?:bash|pwsh|sh|cmd)$/`, which does **not**
  match `shell`, `terminal` or `powershell`. `shell` is DSH's primary shell tool, so the
  whole P0 shell check — the protected-path redirect guard — was **inert for the most
  common call shape**. Measured, same command `echo x > /etc/passwd`: blocked via `bash`,
  **allowed** via `shell`. `engine.ts` now imports `SHELL_TOOLS` from `evaluate.ts`; the
  gate held three copies of that one fact and only one of them was complete.
- **`git push --delete` was parsed as an ordinary push.** `hasDelete` was computed in
  `analyzeSubcommand` but only ever read in the `branch` case, so `git push --delete
  origin main` fell through to the plain-push branch (`destructiveness: 4`) and the
  `DESTRUCTIVENESS_MAP` entry `'push-delete': 5` was dead data.
- **Protected-branch detection mis-fired on any name containing a slash.** The predicate
  stripped everything before the last `/`, so `backup/main` and `feat/release` were read
  as protected. It now strips only known ref prefixes (`refs/heads/`, `refs/tags/`,
  `refs/remotes/<remote>/`). The direction matters: this predicate feeds an **unremovable**
  P0, where a miss still has the keyword / rule / LLM layers behind it, while a false
  positive blocks a legitimate workflow with no recourse.

### Added

- **P0 hard-deny for remote-history rewrite on a protected branch.** A `git push` that
  force-overwrites or deletes `main` / `master` / `production` / `release` / `stable` is
  rejected deterministically, before any LLM call. The pre-existing protection was the
  flat, branch-blind, namespace-overridable keyword `'push --force'`; this is the
  non-negotiable floor beneath it.
  - **Escalate-only by construction, not by convention**: the helper returns
    `string | undefined`, which the caller reads as deny / undecided. It has no way to
    express allow, so wiring it into P0 cannot widen what the gate permits.
  - Deliberately **not** covered (still left to the keyword / rule / LLM layers):
    force-push to a non-protected branch, `git push --force` with no branch named, and
    ordinary pushes.
  - First production use of the command parser (`command-dispatcher`, `command-semantics`,
    `parsers/git`, `parsers/shell-cmds`), which until now was referenced only by its own
    test file.

### Tests

- New suite `test/git-protected-push.spec.ts` (13 cases): parser variants, the
  protected-branch predicate in both directions, full `SHELL_TOOLS` coverage, the four
  deliberately-allowed shapes, and compound-command segmentation
  (`git status && git push --force origin main`).
- **Falsified**: reverting each of the three fixes turns exactly the cases guarding it red
  (7 failures in total), while the deliberate-allow cases stay green.
- Full suite **40 files / 459 cases**; `typecheck` clean.

## [2.1.2] - 2026-09-15

### Added

- **The composer glyph patch now ships with the package.** `scripts/patch-permission-glyph.mjs`
  is whitelisted in `files` and exposed as the **`dsh-perm-gate-patch-glyph`** bin, so re-applying
  the host-bundle patch after a DSH upgrade no longer needs a source checkout:

  ```sh
  npx dsh-perm-gate-patch-glyph            # apply
  npx dsh-perm-gate-patch-glyph --check    # report only; exits 1 if the glyph is missing
  ```

  In a checkout, `npm run patch:glyph` and `npm run patch:glyph:check` do the same.

  It is deliberately **not** a `postinstall`. The script edits a **host** package, and a plugin
  must not rewrite the harness it is installed into without being asked. (pnpm 10+ blocks
  install scripts by default unless they are allowlisted, so a `postinstall` would also have
  been a promise that silently never runs — worse than an explicit command.)

### Changed

- The script is now testable: its pure parts are exported (`sliceEntry`, `applyGlyphPatch`,
  `candidateBundles`, `findBundle`) and pinned by `test/glyph-patch.spec.ts` (11 cases). The
  bracket-balance invariant is the one that matters — the first version of the slicer scanned
  from the wrong bracket and produced an unparseable bundle, which only the script's own
  `node --check` guard caught.
- `candidateBundles` additionally probes the hoisted per-profile scope under `$DSH_HOME`, and the
  CLI is a no-op when imported (the main block runs only when the file is the entry point).

## [2.1.1] - 2026-09-15

### Added

- **Network execution surface (opt-in, off by default).** A local HTTP/CONNECT proxy adjudicates
  the outbound traffic of *shell subprocesses* against the same rules file, plus an interactive
  approval path for targets no rule covers. A `deny` rule is never escalated — approval widens
  reach for an unlisted target, but can never override a rule that says no. New config:
  `networkEnabled` (default `false`), `networkMode`, `networkUnlisted`, `networkUnattributed`,
  `networkBind`, `networkPort`, `networkNoProxy`, `networkInjectEnv`, `networkAskTimeoutMs`,
  `networkGrantTtlMs`. Diagnostics at `GET /api/dsh-perm-gate/network`. Covered by
  `test/network.spec.ts`, `test/proxy-errors.spec.ts`, `test/network-lifecycle.spec.ts` and
  `test/network-approval.spec.ts`.
- **Two 自动审查 tiers.** `permissive` keeps the built-in file sandbox; `permissive-full`
  (label 自动审查（高权限）) pairs the same approval behaviour with `danger-full-access`. A preset's
  `sandbox` and `approval` are independent knobs, and coupling them forced a trade: the
  `workspace-write` sandbox also denies the named pipes a child process needs to start, so
  `git clone`, MSYS2/Cygwin `sh.exe` and ConPTY failed under the only tier the gate was active in.
  Both tiers are in the default `gatePresets`.
- **Rule model expansion.** Six new match dimensions (`params` / `absent` / `agents` / `when` /
  `argv` / `network`), a multi-file rule chain with `searchUp` and a fallback path, and shadow
  detection for unreachable rules. New config: `searchUp`, `fallbackPath`, `badFilePolicy`,
  `maxChainLength`.
- **Rule hot reload.** A chokidar watcher reloads the effective rule files, with debounce, LRU
  eviction across workspaces, and candidate-file watching through the deepest existing ancestor so
  a rules file created mid-session is adopted. New config: `watch` (default `true`),
  `watchDebounceMs`.

### Fixed

- **A blocked connection could kill the host.** When the proxy answered a CONNECT with 403, the
  client's RST produced an `ECONNRESET` with no handler attached yet, which escalated to an
  unhandled `'error'` event and terminated the DSH process. Socket error handlers are now attached
  at connection time, with `clientError` and handler-rejection fallbacks behind them. A policy
  proxy must never take down its host.
- **Built-in tools prompted for approval.** The auto-allow roster was hand-maintained, so tools
  added to DSH since drifted out of it and a read-only call could raise an approval prompt:
  `advanced_search`, `platform_search`, `free_search_test`, `context_compression_retrieve`,
  `memory_search_graph`, `memory_expand_graph_node`, `memory_import`, `memory_ruminate`,
  `memory_ruminate_cancel`, `memory_ruminate_status`.
- **Proxy lifecycle races.** `close()` now awaits an in-flight bind instead of racing it, is
  bounded, and concurrent `start()` calls share one bind. DNS lookups are time-bounded, the
  connection-setup phase is bounded, concurrent connections are capped, and a throwing logger can
  no longer escalate into a crash. Proxy teardown is registered before the bind is awaited, so an
  early dispose cannot leak a bound port or a rewritten `process.env`.
- **`permissive-full` had no icon in the composer picker.** The picker's glyph map is closed and,
  by its own comment, gives host-configured names none, so 自动审查（高权限）rendered text-only
  while 自动审查 showed shield+eye. `scripts/patch-permission-glyph.mjs` adds the missing entry to
  that **host** map: idempotent, bracket-depth slicing, and it `node --check`s its own output and
  restores the backup on failure. It patches a host package, so it must be re-run after every DSH
  upgrade — reinstalling the plugin does **not** restore it, because `dsh plugin … add` only writes
  the profile's own `node_modules`. Documented in all four INSTALL guides.

### Changed

- `networkUnattributed` defaults to `allow`: traffic with no shell attribution is DSH's own client
  (a built-in network tool, the LLM transport), and reviewing it would let the host block itself.
  Set `deny` for the stricter behaviour.
- `networkUnlisted` defaults to `ask`.
- `DEFAULT_GATE_PRESETS` is now `['permissive', 'permissive-full']`.

### Documented

- **The network proxy is a cooperative policy layer, not an enforcement boundary.** It only sees
  clients that read the proxy environment. Measured, not assumed: `curl` routes through it, while
  `node` `http`/`https`/`fetch` connect directly, as do raw sockets, DNS, QUIC, literal-IP targets
  and Java/.NET default clients. Stated in all four READMEs.

- **Session sweep — the authorization chain now follows the session lifecycle.** On plugin startup
  and every hour, the gate reads DSH's workspace store (`$DSH_HOME/storages/workspace.json`,
  read-only) and classifies every session it holds gate 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` (atomic tmp+rename rewrite, only when something is removed)
  and their pre-change snapshot files deleted from `$DSH_HOME/perm-gate/snapshots/`. Live sessions
  are untouched; unattributable rows (empty session id, unparsable line/file) are never deleted;
  every I/O failure is fail-open (the round is skipped and retried an hour later); the hourly
  timer is `unref`'d and disposed with the plugin. New config: `sessionSweep` (default `true`),
  `workspaceStoreFile` (default `<dshHome>/storages/workspace.json`). Note that restoring an
  archived session does not restore its swept history. Covered by `test/session-sweep.spec.ts`
  (13 unit cases) and `test/session-sweep-apply.spec.ts` (end-to-end wiring).

## [2.0.0] - 2026-09-11

### Fixed

- **The gate stood down on every call under DSH 0.1.2, so the Approvals page stayed empty.** The
  session's permission-preset fold read the log as `exec.agent.session.events`. DSH 0.1.1 exposed
  that array; 0.1.2 made the log private behind `Session.snapshotEvents()` / `ownEvents()` and kept
  no `events` member, so the read returned `undefined`, `presetOf` answered `undefined`, and
  `presetInScope(undefined, ['permissive'])` made `gateActive` false for **every** call: no rule,
  grant, deny-keyword, classifier, or P0 hard-deny decision, and no audit event — including in
  sessions that had selected the gate's own tier, which is why the tab showed "本会话暂无审批记录"
  rather than a failure. The fold now reads the log through every known accessor shape
  (`events` array, `snapshotEvents()`, `ownEvents()`) and degrades to "no events" only when the host
  exposes none; the fold cache keys on the log's length plus its last event's identity, since a
  0.1.2 snapshot is a fresh array over the same frozen events on every read. `test/preset-scope.spec.ts`
  pins both shapes: a `0.1.2`-shaped session must yield a decision (and a recorded event), and a
  grown log must re-fold so a preset switch takes effect on the next call.

### Changed

- **The `main` line's version series is now `2.x`, tracking the DSH line it serves.** `1.x`
  is the DSH `<= 0.1.1` line and `2.x` is the DSH `0.1.2+` line, so a plugin version says
  which DSH it was built for; `2.0.0` is the first release of the new series and the
  `0.2.x` releases (`0.2.0`, `0.2.1-beta.2`…`beta.5`) are superseded by it. The majors
  fence each other — `^1.0.0` does not resolve `2.0.0` and `^2.0.0` does not resolve
  `1.0.0` — so an existing `^0.2.1-beta.4` install (which also does not resolve `2.0.0`)
  must be bumped deliberately. `engines.dsh` states the same split
  (`>=0.1.2-alpha.1 <0.2.0-0` here, `>=0.1.0-rc.7 <0.1.2-alpha.1` on `legacy`), but DSH
  never reads it: the version ranges and dist-tags are what keep an old DSH on `1.x`.
- **The permission tier is labelled 自动审查 on every surface, with no icon.** The preset's
  `name:` in `cordis.patch.yml` is now the Chinese product string, and the General-settings default
  row, the composer picker, and the plugin's settings tab all show it. DSH 0.1.2 renders a
  plugin-contributed tier's `name:` verbatim and localizes only the three built-in values
  (`仅可查看` / `工作区内修改` / `完全权限`), so the one host-supplied string is what a zh session sees;
  the tier's machine value stays `permissive`, which is what `gatePresets` matches. The
  permission-picker icon decoration is removed (`src/client/permission-icon.ts` deleted): it existed
  to match the retired `auto` tier's shield glyph, it applied to the settings row through the
  0.1.2 `aria-haspopup="menu"` selector, and a plugin tier draws no glyph because the composer's
  glyphs are keyed to the built-in values only.

## [0.2.1-beta.4] - 2026-09-11

### Added

- `trustEscalation` strategy in the Permissive tier (on by default while the tier is on): a
  sandbox-escalation approval for a call this gate already allowed is answered `allowed-once`
  here instead of prompting you. The escalation is raised by `approveEscalation` from *inside*
  the shell / pwsh / edit tool body — after `tools/pre-execute` settled — so the gate's own
  allow never reached it and a call the LLM graded `safe` still asked you to approve the
  widening. Only the exact call the gate cleared (matched by `callId` and tool name) skips the
  prompt, and only for a recognized escalation reason naming `workspace-write` /
  `danger-full-access`; everything else delegates to the human unchanged. The auto-answer is
  recorded on the event feed (`verdict:"escalation-auto"`, `mode:<target>`). Turn the switch
  off to keep sandbox widening human-gated.

### Fixed

- The settings card now seeds the editable whitelist from the rules file. `installSettingsSection`
  called `scope.set('allowlist', …)` on the **host** settings scope, which exposes only
  `get` / `watch` / `update` / `replace` — `set(field, value)` is the *client* convenience wrapper
  over `mutate()`, a different object — so the call threw and the namespace stayed unseeded.
  It now uses `scope.update({ allowlist: … })`.
- The `approval/request` listener is registered `prepend` and is now an answering gate rather than
  a passive observer, so it sits ahead of the remote bridge that renders the browser prompt. A
  listener behind that bridge could only ever record a prompt that had already been shown.
- The browser half no longer imports `@deepseek-ai/dsh-client-runtime`, which DSH removed in
  `0.1.2-alpha.1`. `ClientContext` now comes from `@deepseek-ai/cordis` — the alias DSH 0.1.1
  defined as `export type ClientContext = Context`, so it names the same type on both lines — and
  the settings card declares the four scope members it uses locally (`SettingsScopeLike`), the
  same local-face pattern the host half already uses for the host `settings` service. The client
  contract is byte-identical on both lines; only its exporting package moved.

### Changed

- DSH compatibility now ships as **two long-lived branches**, each with its own version series,
  `engines.dsh`, and npm dist-tag: `main` / `0.2.x` / `>=0.1.2-alpha.1 <0.2.0-0` / `latest`, and
  `legacy` / `1.x` / `>=0.1.0-rc.7 <0.1.2-alpha.1` / `legacy`. This branch is `main`. See
  `RELEASING.md` for the branch layout and the cherry-pick flow.
- On this line `ctx.slots` is declared by `@deepseek-ai/dsh-client-ui-renderer/client` — from
  `0.1.2-alpha.1` on, `@deepseek-ai/dsh-client-runtime` is gone — so the client entry imports it.
- Client devDependency floors raised from `^0.1.0-rc.7` to `^0.1.5-rc.2`, and
  `@deepseek-ai/dsh-client-ui-renderer` added. The old floor meant the build could only ever
  resolve the 0.1.0-rc.8 package set, so this line was never compiled against.
- `npm run verify:line` (`scripts/verify-line.mjs`) installs this branch's line packages with
  `npm install --no-save` and runs typecheck + tests + build against them, so a build always
  compiles against the packages the branch ships for. `npm run verify:lines` is an opt-in drift
  check that builds on both lines and compares the bundles.

## [0.2.1-beta.3] - 2026-09-10

### Added

- Risk-graded `llmAssist`: the configured custom LLM (any OpenAI-compatible endpoint) grades each
  `ask` as `safe` / `risky:<category>`; hard categories (deletion / credential / remote / system /
  bulk) auto-deny, timeouts retry once, and every failure stays fail-closed.
- Verdict learning (`riskLearning`, off by default; `riskThreshold` 1–10, default 3): neutral-risk
  asks confirmed by a human and actually executed count toward auto-allowing the exact same
  operation (fingerprint-matched) — persisted to `$DSH_HOME/perm-gate/learning.json`, never into
  the user's YAML rules.
- Decision event feed: every decision appends to `$DSH_HOME/perm-gate/events.jsonl` and is served
  at `GET /api/dsh-perm-gate/events?sessionId=&since=`; the browser half shows the latest decision
  as a notice strip above the conversation input.
- Permission-picker icon now also decorates the collapsed picker trigger.
- Approval-records page: the conversation view gains an **Approvals** tab listing the session's
  gate decisions newest-first (timeline with kind tags, risk category, and time); the settings
  card's whitelist is now an editable rule list with per-row delete plus a bulk-edit toggle.
- Learning sedimentation (`riskSediment`, default on): a threshold-reached key's confirmed samples
  become deterministic auto-allow rules — exact fingerprint hits skip the LLM call entirely and
  survive the llmAssist switch; the settings card lists them with per-key terminate and per-sample
  remove (backed by `GET/POST /api/dsh-perm-gate/learning`).
- Selectable llmAssist receiver: **custom API** (OpenAI-compatible, with endpoint presets
  including Xiaomi MiMo `https://api.xiaomimimo.com/v1`) or the **DSH host model group**
  (`llm` service + `agentDefaultModel.currentSelection`, provider/model overridable) — plus a
  **health test** button (`POST /api/dsh-perm-gate/health`) that runs one minimal completion
  and reports latency. The card reads the live provider/model-group catalog
  (host `llm.listProviders`/`listModels` — user-configured custom groups included) via
  `GET /api/dsh-perm-gate/receiver` and shows the effective provider/model selection.
- Preset deny-keyword blacklist inherited from dsh-approval-gate (`DEFAULT_DENY_KEYWORDS`): a
  case-insensitive keyword hit vetoes the call before whitelist / grants / LLM; editable as a
  list in the settings card with preset tags and one-click restore; unset or empty applies the
  preset (the blacklist never silently turns off).
- **Approval-records review plane**: every decision's affected files are snapshotted before the
  change lands (≤5 files, ≤256 KB each) under `$DSH_HOME/perm-gate/snapshots/`; the **Approvals**
  tab turns those files into clickable chips that open a line diff (`GET /api/dsh-perm-gate/diff`)
  with a **revert** action delivering a restore instruction into the conversation
  (`POST /api/dsh-perm-gate/revert`), plus a snapshot inventory bar
  (`GET /api/dsh-perm-gate/snapshots-stats`, `POST /api/dsh-perm-gate/snapshots-clear`,
  session-scoped or all). Event rows now carry `files`, `justification`, `verdict`, and `category`.
- **Manual-approval terminal records**: an `ask` routed to a human is tracked and settled with the
  human's actual answer by a passive `approval/request` observer — `allowed-once` → approved,
  `rejected` → rejected, `cancelled` → cancelled, `unavailable` → a denial (no approval channel).
  `tools/result` settles the same ask as a fallback when the observer cannot correlate it (missing
  `callId`, no approval service, or an earlier listener short-circuiting the waterfall);
  "delete on settle" is the whole de-duplication rule. Approvals report the post-approval learning
  progress (`n`/threshold), and the notice strip labels all three terminal states.

### Removed

- The retired **Auto** permission tier is no longer restated in `cordis.patch.yml`. The DSH bundle
  patch replaces the whole `permission.config.presets` map, so this file had to carry every tier
  that should survive — including `auto`, which `dsh-auto-mode` contributed. That plugin is
  uninstalled: its knobs were identical to `workspace-write`, its "automatic review / one-shot
  approval" description lost its implementation with it, and this gate is inactive in that tier
  (`gatePresets` defaults to `['permissive']`). The picker now offers the three built-ins
  (`read-only` / `workspace-write` / `danger-full-access`, restated from
  `@deepseek-ai/dsh-base/cordis.patch.yml`) plus this plugin's `permissive`.
- `test/patch-presets.spec.ts` pins that exact key set, so a built-in tier can no longer be dropped
  (or a retired one resurrected) without failing the suite.

### Changed

- **DSH dual-version support (0.1.0-rc.7 … 0.1.2-rc.1).** One artifact now covers both
  `dsh-v0.1.1-rc.2` and `dsh-v0.1.2-rc.1` without a version check. `effectivePolicy` is a
  **private** method of the user-approval service in both versions, so it is read behind a
  `typeof` probe and a throwing/absent probe now degrades to “policy unknown” (the ask stands)
  instead of failing the decision path. Settings registration keeps `ctx.settings.register`,
  which exists in every supported version. `dsh.client.inject` no longer names
  `@deepseek-ai/dsh-client-runtime` (removed in 0.1.2) or `@deepseek-ai/dsh-client-ui-slots`
  (not a dynamic client row in either version); it now lists the real client rows it renders
  into. Added `engines.dsh` and a version-compatibility table to the README.
- The gate is **active only in the permission tiers listed in `gatePresets`** (default
  `['permissive']`, the tier this plugin adds). In every other tier — Read Only, Workspace Write,
  Auto, Full access, `custom` — its 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. `gatePresets: ['*']` makes the gate
  global again (hard-deny included); an embedding that passes no scope keeps the legacy behaviour.
- `llmAssist` now uses the risk-category protocol (superset of the previous allow/deny/ask verdicts);
  `classifier.ts` shares one OpenAI-compatible transport (`chatCompletion`) with the risk grader.
  Hard-risk categories (`deletion` / `credential` / `remote` / `system` / `bulk`) now **auto-deny**
  instead of routing to the human — the operation is clearly dangerous and no popup is needed;
  only `risky:neutral` (uncertain) still reaches the human seam.
- `write` / `edit` tools now have a **path-aware default**: workspace-internal writes follow the
  configured `defaultAction` (typically `allow`); writes to paths outside the session workspace
  escalate to `ask` so the content can be reviewed. The LLM classifier can then auto-allow safe
  content or auto-deny harmful content, so only truly uncertain operations produce a popup.

### Fixed

- **`llmAssist` "safe" now actually auto-allows instead of only being recorded.** The
  `tools/pre-execute` listener returned the `ask` immediately and graded the call in the
  background, but an ask handed to the host is already on its way to the approval answerers and
  DSH offers no API to retract it (the request's own `signal` can only settle it `cancelled`). The
  panel therefore still appeared and the human still had to click, while the feed showed the
  `safe` auto-allow — and a hard-risk `auto-deny` never reached the host at all. The listener now
  awaits `refineAsk` **before** returning the decision (`makePreExecuteListener`): `safe` delegates
  via `next()` with no panel, a hard-risk category auto-denies, and only `risky:neutral` /
  `unresolved` / a grader failure keep the human ask (fail-closed). The wait is bounded by
  `riskTimeoutMs` (default 20 s) and an already-cancelled call skips grading.
- **The shell write-pattern layer never ran for DSH's primary shell tool.** `SHELL_TOOLS` listed
  `bash` / `pwsh` / `sh` / `cmd` / `powershell` but not **`shell`** (DSH's own tool name) or
  `terminal`, so `git push`, `chmod`, `tee`, redirects and every other write pattern fell through
  to `defaultAction` instead of escalating to `ask`. The roster is now
  `shell` / `terminal` / `bash` / `pwsh` / `sh` / `cmd` / `powershell`, and the duplicate copy in
  `learning.ts` was removed (single source in `evaluate.ts`, so the decision path and the learning
  fingerprint cannot drift again).
- **Redirect and `tee` detection was too narrow.** Only `>>` matched, so `echo x > /tmp/out.txt`
  and a bare `tee /tmp/log.txt` were treated as read-only. `> f` / `>> f` / `>f` now escalate while
  the stderr idioms a read-only command uses (`2>&1`, `>&2`, `2>/dev/null`, `1>&2`) stay
  read-only; `curl` / `wget` escalate only when they write a file (`-o` / `-O` / `>`).
- **`decideRules` ignored `args.command`.** It read only the derived `ctx.commandText`, so any
  caller that built a context by hand (tests, embedders) silently lost command inspection. It now
  falls back to `args.command`, matching `PermGateRuntime.ctxFor`.
- **The gate overruled the permission tier the user selected.** It owns an independent approval
  tier, but it acted in *every* preset: any crossing became an `ask`, `dsh-tools` forwarded it to
  the DSH approval seam, and under the `danger-full-access` preset (`approval: never`) that seam
  returns `rejected` **before any answerer runs** — no panel was ever rendered and every
  non-read-only call failed with the misleading `the user rejected tool "..."`. Its hard-deny and
  deny-keyword layers likewise ignored the tier, so `danger-full-access` ("full access without
  approval prompts") was silently narrowed. The whole gate is now scoped to `gatePresets`; outside
  it the gate is inert and records nothing. Inside an active tier an `ask` is still degraded to
  passthrough when the session's effective approval policy is `never`, so an unanswerable ask can
  never become a denial.
- **Read-only search and session-local tools were asked about.** `web_search` and
  `modlens_read_image` are read-only queries and `todo_write` / `render_ui` / `validate_dsh_ui` /
  `ask_user_question` / `exit_plan_mode` / `ralph` / `workflow` are session-local state or
  delegation, but none were in the auto-allow classification, so each one raised an `ask` (the
  `no rule matched; default action` popup). They are auto-allowed now, and a new `autoAllowTools`
  config list extends the classification for third-party read-only tools. P0 hard-deny and the
  deny-keyword layer still run first, so the list can never widen authority.
- **`write` / `edit` to paths outside the workspace silently passed through.** A call that writes
  to `~/.bashrc` or another drive's files received the configured `defaultAction` (typically `allow`)
  and executed without review. A path-aware override now applies to write/edit tools when no rule
  matches: workspace-internal writes follow the configured `defaultAction`, but any target path
  outside the session's workspace escalates to `ask` so the content can be reviewed — harmful
  content is denied; benign additions are auto-allowed when `llmAssist` is enabled. Explicit deny
  rules still win regardless of path scope, and explicit allow rules can grant access to inside
  paths but never bypass the review for outside ones.
- **P0 credential detection scanned document bodies**, so writing or editing any file that merely
  *mentioned* a token, a private key, or `credentials.yaml` was hard-denied. It now scans only the
  arguments that describe the operation (`command`, `file_path`, …), matching the deny-keyword
  layer's existing rule that a file's text is not the operation.
- **A `$DSH_HOME/perm-gate/rules.yml` was never loaded.** `rulesFile` had no default, so a profile
  entry that omitted `config` left the gate with an empty ruleset and `defaultAction: ask` — the
  user's `defaultAction: allow` and `allow:` rules were silently ignored. `rulesFile` now defaults
  to `<dataDir>/rules.yml` (like `eventsFile` / `learningFile`), and the settings card's whitelist
  mirror actually writes there.
- **`format` vetoed the PowerShell cmdlet `Format-Table`.** A hyphen is not a word boundary for a
  command identifier, so the blacklist matched `Format-Table` / `Format-List`. Keyword edges now
  match against identifier characters (`[A-Za-z0-9_-]`), so `format C: /q` is still caught while
  `Format-Table` and `mkfs.ext4` behave as before.
- **Every read query required manual approval.** `read` / `read_image` / `grep` / `glob` / `ls` / `lsp`
  are workspace-only read operations that can't modify anything (P0 hard-deny still blocks reads
  of sensitive paths outside the workspace root). They are now auto-allowed. The same applies to
  all DSH internal coordination tools (`agent_teams_*`, `conversation_search`, `memory_*`,
  `get_goal` / `update_goal` / `create_goal`, `taskboard_*`, `job_*`, `list_agents` /
  `interrupt_agent` / `send_message`, `subagent` / `subagent_fork` / `terminal` / `skill`).

- **The Approvals tab resolved no session id**, so it listed nothing while the snapshot bar showed
  the global inventory. A `conversation.view` occupant receives the standard props at the top level
  (`sessionId` / `useSessions`) — the shape `ConversationRoot` consumes — not under `slotsProps`;
  the view and the notice strip now probe the top level, the nested shape, and the session-list hook.
- **Events were recorded with an empty session id**, so the Approvals tab (which filters by the
  current session) showed nothing even though `events.jsonl` was filling up. DSH's `ToolExecution`
  carries the session on `agent.session.id` (workspace cwd on `agent.session.header.cwd`), not on
  `exec.sessionId` / `exec.cwd`; both are now read from the agent when the direct fields are absent.
- **The review page was silently dead on a default install**: with a profile entry that omits
  `config`, `dshHome` was empty, so the data directory was `undefined` and the event log was never
  constructed — no `events.jsonl`, no snapshots, no persisted learning, and a 404 on
  `GET /api/dsh-perm-gate/events`. `dshHome` now resolves to an explicit value, else `$DSH_HOME`,
  else `~/.dsh`, so `<dshHome>/perm-gate/{events.jsonl,learning.json,snapshots/}` is always written.
- Deny-keyword matching no longer vetoes a keyword that merely appears inside a longer identifier,
  and document-body arguments (`content`, `new_string`, `old_string`, `text`, …) are no longer
  scanned at all — a file's text is not the operation. Matching is word-boundary aware now, so
  punctuation-edged and CJK keywords keep working, and whitespace is collapsed so a command
  written with extra spaces is still caught.


### Added
- **Permissive tier** — an independent approval mode parallel to read-only / workspace-write /
  full-access / whitelist. A single front switch (`permissive`) plus combinable backend
  strategies (`trustAutoAllow` / `alwaysConfirm` / `llmAssist`); P0 hard-deny stays monotonic.
- Audit `classifier` / `permissive` decision sources; optional injectable `classify` hook for
  `llmAssist` (fail-closed on `ask`/absence).
- **Browser client** — a `settings.plugins.tab` page ("Permissive approval tier") renders in
  Settings → Plugins: one switch (`permissive`) + three backend `permissiveStrategies` toggles.
  The host reads the namespace live, so edits apply to the next tool call without a restart.
- CLI `--permissive` flag and permissive summary in `--list`.
- **Real llmAssist LLM** — a configurable OpenAI-compatible classifier (`classifierEndpoint` /
  `classifierModel`), invoked to auto-decide an `ask` and fail-closed to the human seam.
- **Whitelist migration** — `approveAllowEverywhere` persists a command word into the rules
  file's `allow` whitelist and reloads; `approveRepeat` mints a bounded session grant. Both back
  the always-confirm panel's two extended allow buttons.
- **Four-language install guides** — `INSTALL.md` / `INSTALL.zh.md` / `INSTALL.ja.md` /
  `INSTALL.ko.md` covering install, upgrade, migration from the split plugins, verification and
  troubleshooting; every README gained language-switch links, a `ja` / `ko` compatibility note
  (official DSH `LOCALE_IDS` is `["zh", "en"]`) and a version reference.
- README.ja / README.ko expanded to full mirrors of the English source of truth (P0–P4 chain
  table, why/features, rules-file format, Permissive tier, CLI).

## [0.1.0] - 2026-08-30

### Added
- P0–P4 decision chain: hard-deny, session grant, static deny-first rule chain, optional classifier (off), `ask`.
- Pure-function rule engine: glob/regex compilation with ReDoS bound, loud-fail parsing, content-hash compile cache.
- Command whitelist/blacklist via argv decomposition (`sh -c`/`bash -c` recursion, pipelines, redirects, recursive/force).
- Precise session grants keyed by canonical fingerprint (TTL + maxUses, no cross-target reuse).
- `{ignorable:true}` decision audit with model-visible⟺logged invariant.
- Standalone CLI dry-run evaluator (`dsh-perm-gate --rules … --tool … --args …`).
- DSH cordis function-plugin contract (`cordis.patch.yml`, Schemastery `Config`, exports/types).
- 4-language README (en/zh/ja/ko) and Keep-a-Changelog framework (en + ja + ko).