dsh-perm-gate 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.ja.md +62 -0
  2. package/CHANGELOG.ko.md +60 -0
  3. package/CHANGELOG.md +62 -0
  4. package/INSTALL.ja.md +152 -0
  5. package/INSTALL.ko.md +151 -0
  6. package/INSTALL.md +154 -0
  7. package/INSTALL.zh.md +147 -0
  8. package/LICENSE +21 -0
  9. package/README.ja.md +197 -0
  10. package/README.ko.md +195 -0
  11. package/README.md +209 -0
  12. package/README.zh.md +144 -0
  13. package/assets/.gitkeep +0 -0
  14. package/assets/README.md +5 -0
  15. package/cordis.patch.yml +35 -0
  16. package/examples/permissions.example.yaml +20 -0
  17. package/lib/allowlist.d.ts +19 -0
  18. package/lib/allowlist.js +97 -0
  19. package/lib/audit.d.ts +45 -0
  20. package/lib/audit.js +34 -0
  21. package/lib/classifier.d.ts +74 -0
  22. package/lib/classifier.js +129 -0
  23. package/lib/cli.d.ts +2 -0
  24. package/lib/cli.js +67 -0
  25. package/lib/client.js +859 -0
  26. package/lib/client.js.map +1 -0
  27. package/lib/compiler.d.ts +30 -0
  28. package/lib/compiler.js +126 -0
  29. package/lib/config.d.ts +69 -0
  30. package/lib/config.js +64 -0
  31. package/lib/engine.d.ts +17 -0
  32. package/lib/engine.js +85 -0
  33. package/lib/evaluate.d.ts +20 -0
  34. package/lib/evaluate.js +94 -0
  35. package/lib/events.d.ts +56 -0
  36. package/lib/events.js +129 -0
  37. package/lib/grant.d.ts +60 -0
  38. package/lib/grant.js +121 -0
  39. package/lib/index.d.ts +17 -0
  40. package/lib/index.js +140 -0
  41. package/lib/learning.d.ts +58 -0
  42. package/lib/learning.js +181 -0
  43. package/lib/path.d.ts +47 -0
  44. package/lib/path.js +94 -0
  45. package/lib/risk.d.ts +58 -0
  46. package/lib/risk.js +100 -0
  47. package/lib/rule.d.ts +64 -0
  48. package/lib/rule.js +220 -0
  49. package/lib/runtime.d.ts +146 -0
  50. package/lib/runtime.js +350 -0
  51. package/lib/shell.d.ts +38 -0
  52. package/lib/shell.js +176 -0
  53. package/package.json +120 -0
package/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # dsh-perm-gate
2
+
3
+ - [English README](./README.md)
4
+ - [中文 README](./README.zh.md)
5
+ - [日本語 README](./README.ja.md)
6
+ - [한국어 README](./README.ko.md)
7
+ - [Installation guide](./INSTALL.md)
8
+ - [中文安装指南](./INSTALL.zh.md)
9
+ - [日本語インストールガイド](./INSTALL.ja.md)
10
+ - [한국어 설치 안내](./INSTALL.ko.md)
11
+ - [Changelog](./CHANGELOG.md)
12
+ - [日本語 changelog](./CHANGELOG.ja.md)
13
+ - [한국어 changelog](./CHANGELOG.ko.md)
14
+
15
+ > **Compatibility note:** v0.1.0 ships `ja` / `ko` dictionaries, but official DSH exposes
16
+ > only `zh` / `en` through `LocaleRuntime` (`LOCALE_IDS = ["zh", "en"]`). On stock DSH,
17
+ > selecting `ja` / `ko` fails with `locale "<id>" is not registered`. Use a DSH fork that
18
+ > updates `LOCALE_IDS` (locale-settings.ts) and `LOCALES` labels (client/index.ts), then
19
+ > rebuild.
20
+
21
+ Version **0.1.0** — see the [Changelog](./CHANGELOG.md).
22
+
23
+ A single, self-sufficient, deterministic-first, fail-closed permission gate for DeepSeek Harness.
24
+
25
+ `dsh-perm-gate` decides every tool call through a fixed priority chain:
26
+
27
+ | Stage | Decision | What it is |
28
+ | ---- | ---- | ---- |
29
+ | **P0** | `deny` | deterministic hard-deny: credential material, protected-path mutation, dangerous shell |
30
+ | **P1** | `allow` | a precise, bounded **session grant** |
31
+ | **P2** | `deny/allow/ask` | static rule chain: blacklist first, then allow, then ask |
32
+ | **P3** | `allow/deny/ask` | optional LLM semantic classifier (default **off**) |
33
+ | **P4** | `ask` | official approval seam |
34
+
35
+ Strictly fail-closed: a P0 decision is never overridden by a grant, a rule, the classifier, or a human.
36
+
37
+ ## Why
38
+
39
+ The DSH safety ecosystem splits this across several plugins (`dsh-permission-rules`,
40
+ `dsh-auto-mode`, `dsh-auto-review`, `dsh-movein-permissions`). `dsh-perm-gate` merges the
41
+ gate + approval + (optional) classifier into one package, with one audit trail and no
42
+ cross-plugin version coupling.
43
+
44
+ ## Features
45
+
46
+ - **Command whitelist / blacklist** — matched on an **argv decomposition** (not a raw string),
47
+ with recursive descent into `sh -c`/`bash -c`, pipeline detection, redirect-target
48
+ checking, and recursive/force (`rm -rf`) recognition.
49
+ - **Deny priority** — a matching deny rule wins over any allow rule.
50
+ - **Session grants** — precise `(tool, canonical fingerprint)` grants with `TTL` + `maxUses`;
51
+ re-running with a different target never reuses authority. Sub-agents inherit but cannot mint.
52
+ - **Pure-function rule engine** — glob/regex compilation with a ReDoS bound, loud fail on
53
+ malformed rules, and source-hash compile caching.
54
+ - **Audit** — every decision is logged as an `{ignorable:true}` event with its `callId`; the
55
+ model-visible reason matches the recorded outcome.
56
+ - **Permissive tier** — an **independent approval mode** (separate from read-only, full-access
57
+ and whitelist tiers) that is neither "auto-approve" nor blanket trust. Front-end exposes a
58
+ **single switch** (`permissive`); the three backend strategies are **combinable** and driven
59
+ by plugin settings — still fail-closed against P0.
60
+ - **Risk-graded `llmAssist`** — a custom OpenAI-compatible LLM grades each `ask` as
61
+ `safe` / `risky:<category>`; hard categories (deletion, credential, remote, system, bulk)
62
+ **always ask**, neutral enters verdict learning, and every failure stays fail-closed.
63
+ - **Verdict learning** — neutral-risk asks that the human approves and that actually execute
64
+ count up; after the threshold, the *exact same operation* (fingerprint-matched) auto-allows.
65
+ - **Decision event feed** — every decision is appended to a JSONL feed and surfaced by the
66
+ browser half as a notice strip above the conversation input.
67
+
68
+ ## Install
69
+
70
+ Requires an existing [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) installation.
71
+
72
+ ```sh
73
+ dsh plugin --profile web add dsh-perm-gate
74
+ ```
75
+
76
+ Full install, upgrade, migration and troubleshooting steps live in the
77
+ [Installation guide](./INSTALL.md) — also available in
78
+ [中文](./INSTALL.zh.md) / [日本語](./INSTALL.ja.md) / [한국어](./INSTALL.ko.md).
79
+
80
+ ## Configuration
81
+
82
+ Add the plugin to `cordis.yml`:
83
+
84
+ ```yaml
85
+ - id: dsh-perm-gate
86
+ name: dsh-perm-gate
87
+ config:
88
+ rulesFile: ./permissions.yaml # optional; empty = allow everything per defaultAction
89
+ dshHome: $DSH_HOME # root pinned for protected-target checks
90
+ defaultAction: ask # allow | ask | deny
91
+ ```
92
+
93
+ ### Rules file
94
+
95
+ ```yaml
96
+ permissions:
97
+ defaultAction: ask
98
+ deny:
99
+ - command: [rm#recursive]
100
+ reason: no recursive rm
101
+ - command: [ssh]
102
+ reason: no direct ssh
103
+ - paths: [.dsh/**]
104
+ reason: protect harness metadata
105
+ allow:
106
+ - command: [pnpm, node]
107
+ reason: dev tools
108
+ - command: [curl, wget]
109
+ args: ["https://*.example.com/*"]
110
+ reason: allowed endpoint
111
+ ask:
112
+ - command: [bash, sh]
113
+ reason: ask shells
114
+ ```
115
+
116
+ A command entry `word#flag` matches the command word (`word`) with the modifier `recursive` or
117
+ `force` — so `rm#recursive` matches `rm -rf`, `env rm -rf`, and `sh -c "rm -rf /"`.
118
+
119
+ ## Permissive mode
120
+
121
+ Permissive is an **independent approval tier** in the DSH permission picker, parallel to
122
+ Read Only / Workspace Write / Full access / Whitelist. It is **not** "auto-approval" and never
123
+ mints blanket authority: it only narrows or widens the seam *before* the human/LLM step while
124
+ P0 hard-deny stays monotonic and non-negotiable.
125
+
126
+ In `cordis.yml`:
127
+
128
+ ```yaml
129
+ - id: dsh-perm-gate
130
+ name: dsh-perm-gate
131
+ config:
132
+ rulesFile: ./permissions.yaml
133
+ defaultAction: ask
134
+ permissive: true # single front-facing switch (independent tier on)
135
+ permissiveStrategies: # backend, combinable
136
+ trustAutoAllow: true # in-scope safe ops auto-allow; dangerous/unknown ask
137
+ alwaysConfirm: false # every crossing asks; allow-controls add repeat-allow / wl-migrate buttons
138
+ llmAssist: false # LLM classify first, human fallback on ask/failure
139
+ ```
140
+
141
+ `trustAutoAllow` is the baseline middle tier (rule-allow auto-passes). `alwaysConfirm` surfaces
142
+ the approval panel for every crossing; its "allow controls" add two extended buttons —
143
+ **repeat-allow this type this session** (a bounded session grant) and **allow every occurrence**
144
+ (which persists the command word into the `permissions.yaml` allow whitelist via
145
+ `approveAllowEverywhere`). `llmAssist` consults a real, configurable LLM (`classifierEndpoint` /
146
+ `classifierModel`, any OpenAI-compatible API) to auto-decide an `ask`, and falls back to the
147
+ human seam on `ask`/error — always fail-closed. When `permissive` is off, the gate behaves
148
+ exactly as before.
149
+
150
+ ### Risk-graded llmAssist, verdict learning, and the event feed
151
+
152
+ When `llmAssist` is on, the configured LLM (any OpenAI-compatible endpoint — point
153
+ `classifierEndpoint` / `classifierModel` / `classifierApiKey` at your own API) grades one
154
+ `ask` at a time with a structured protocol:
155
+
156
+ - `safe` → the ask is auto-allowed (audited as the `classifier` source).
157
+ - `risky` + a **hard category** (`deletion`, `credential`, `remote`, `system`, `bulk`) →
158
+ the ask **always** reaches the human; hard risks are never auto-allowed and never learned.
159
+ - `risky:neutral` → with `riskLearning` enabled (Settings card, off by default), each human
160
+ approval that actually executes (settled via the host's `tools/result` event) counts toward
161
+ a `tool|category` key; once the count reaches `riskThreshold` (default 3) **and** the new
162
+ call's operation fingerprint (command word + target basename) matches a confirmed sample,
163
+ the exact same operation auto-allows. A different target never reuses that authority.
164
+ - Timeouts (`riskTimeoutMs`, default 20 s, one retry), transport failures, and off-protocol
165
+ model output leave the ask untouched — the gate never guesses.
166
+
167
+ Learning state persists to a plugin-owned JSON (`$DSH_HOME/perm-gate/learning.json`, or
168
+ `learningFile`), never into your YAML rules file. Every decision is appended to
169
+ `$DSH_HOME/perm-gate/events.jsonl` (or `eventsFile`) and served at
170
+ `GET /api/dsh-perm-gate/events?sessionId=&since=`; the browser half polls it and shows the
171
+ latest decision as a notice strip above the conversation input (asks stay visible until the
172
+ next event). The Permissive tier keeps its shield icon in the permission picker — on both the
173
+ menu item and the collapsed picker trigger.
174
+
175
+ ### A selectable session tier
176
+
177
+ `cordis.patch.yml` extends the DSH `permission.config.presets` with a `permissive` preset
178
+ (`sandbox: workspace-write`, `approval: ask`, name **Permissive**) between Workspace Write and
179
+ Full access — the same mechanism the Auto mode uses. So the session permission picker offers
180
+ Permissive as an independent selectable approval tier, not an "auto-approval" mode.
181
+
182
+ ### Configurable in the UI
183
+
184
+ The tier is also adjustable at runtime from **Settings → Plugins → Permissive approval tier**
185
+ (a `settings.plugins.tab` page rendered by the plugin's browser client): one switch toggles
186
+ `permissive`, and three toggles edit the backend `permissiveStrategies`. The host reads the
187
+ namespace live, so a change applies to the next tool call without a restart. This is an
188
+ independent approval class, NOT the DSH "auto-approval" mode.
189
+
190
+ ## CLI
191
+
192
+ Dry-run one call against a rules file (no harness needed):
193
+
194
+ ```sh
195
+ dsh-perm-gate --rules permissions.yaml --tool bash --args '{"command":"pnpm install"}'
196
+ dsh-perm-gate --rules permissions.yaml --list
197
+ ```
198
+
199
+ ## Development
200
+
201
+ ```sh
202
+ npm run typecheck
203
+ npm test
204
+ npm run build
205
+ ```
206
+
207
+ ## License
208
+
209
+ [MIT](./LICENSE)
package/README.zh.md ADDED
@@ -0,0 +1,144 @@
1
+ # dsh-perm-gate
2
+
3
+ - [English README](./README.md)
4
+ - [中文 README](./README.zh.md)
5
+ - [日本語 README](./README.ja.md)
6
+ - [한국어 README](./README.ko.md)
7
+ - [Installation guide](./INSTALL.md)
8
+ - [中文安装指南](./INSTALL.zh.md)
9
+ - [日本語インストールガイド](./INSTALL.ja.md)
10
+ - [한국어 설치 안내](./INSTALL.ko.md)
11
+ - [Changelog](./CHANGELOG.md)
12
+ - [日本語 changelog](./CHANGELOG.ja.md)
13
+ - [한국어 changelog](./CHANGELOG.ko.md)
14
+
15
+ > **兼容性说明:** v0.1.0 自带 `ja` / `ko` 字典,但官方 DSH 的 `LocaleRuntime`
16
+ > 只暴露 `zh` / `en`(`LOCALE_IDS = ["zh", "en"]`)。在原版 DSH 上选择 `ja` / `ko`
17
+ > 会报 `locale "<id>" is not registered`。请使用更新了 `LOCALE_IDS`
18
+ > (locale-settings.ts)与 `LOCALES` 标签(client/index.ts)的 DSH fork 并重新构建。
19
+
20
+ 版本 **0.1.0** —— 变更见 [Changelog](./CHANGELOG.md)。
21
+
22
+ 一个**单一自足、确定性优先、fail-closed** 的 DeepSeek Harness 权限门插件。
23
+
24
+ 对每个工具调用按固定优先级链裁决:
25
+
26
+ | 阶段 | 决策 | 含义 |
27
+ | ---- | ---- | ---- |
28
+ | **P0** | `deny` | 确定性硬拒:凭据材料 / 受保护路径改写 / 危险 shell |
29
+ | **P1** | `allow` | 精确、有界的**会话放行** grant |
30
+ | **P2** | `deny/allow/ask` | 静态规则链:黑名单优先,其次 allow,再 ask |
31
+ | **P3** | `allow/deny/ask` | 可选 LLM 语义分类器(默认**关闭**) |
32
+ | **P4** | `ask` | 官方 approval seam |
33
+
34
+ 严格 fail-closed:P0 永不因 grant / 规则 / 分类器 / 人工而放行。
35
+
36
+ ## 特性
37
+
38
+ - **命令白/黑名单** — 基于 **argv 分解**匹配(非裸字符串),递归下钻 `sh -c`/`bash -c`、识别管道、重定向目标、递归/强制(`rm -rf`)。
39
+ - **deny 优先** — 命中黑名单即拒绝,胜过任何 allow。
40
+ - **会话放行** — 精确的 `(工具, 规范化 fingerprint)` grant,带 `TTL` + `maxUses`;换目标绝不复用。子代理继承但不可自授。
41
+ - **纯函数规则引擎** — glob/regex 编译 + ReDoS 上限、坏规则 loud fail、按源内容哈希缓存。
42
+ - **审计** — 每次决策写为 `{ignorable:true}` 事件并带 `callId`;模型可见理由与记录一致。
43
+ - **Permissive 档位** — 一个**独立审批模式**(区别于只读、完全权限与白名单档),既不是"自动审批",也不授予泛化权限。前端只暴露**一个开关**(`permissive`),后台三个审批策略**可组合**、由插件设置决定——仍对 P0 保持 fail-closed。
44
+
45
+ ## 安装
46
+
47
+ 需要先安装 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)。
48
+
49
+ ```sh
50
+ dsh plugin --profile web add dsh-perm-gate
51
+ ```
52
+
53
+ 完整的安装、升级、迁移与排查步骤见[中文安装指南](./INSTALL.zh.md)(另有
54
+ [English](./INSTALL.md) / [日本語](./INSTALL.ja.md) / [한국어](./INSTALL.ko.md))。
55
+
56
+ ## 配置
57
+
58
+ `cordis.yml`:
59
+
60
+ ```yaml
61
+ - id: dsh-perm-gate
62
+ name: dsh-perm-gate
63
+ config:
64
+ rulesFile: ./permissions.yaml
65
+ dshHome: $DSH_HOME
66
+ defaultAction: ask
67
+ ```
68
+
69
+ 规则示例:见 [examples/permissions.example.yaml](./examples/permissions.example.yaml)。
70
+
71
+ ## Permissive 档位
72
+
73
+ Permissive 是权限下拉框里一个**独立审批档**,与 Read Only / Workspace Write / Full access /
74
+ Whitelist 平行。它**不叫"自动审批"**、也不授予泛化权限:只会在人类/LLM 接缝**之前**收窄或放宽决策,
75
+ P0 硬拒绝始终单调且不可协商。
76
+
77
+ `cordis.yml`:
78
+
79
+ ```yaml
80
+ - id: dsh-perm-gate
81
+ name: dsh-perm-gate
82
+ config:
83
+ rulesFile: ./permissions.yaml
84
+ defaultAction: ask
85
+ permissive: true # 前端唯一的开关(启用独立档)
86
+ permissiveStrategies: # 后台策略,可组合
87
+ trustAutoAllow: true # 作用域内安全操作自动放行;危险/未知转 ask
88
+ alwaysConfirm: false # 一律逐次 ask;允许控件附带重复允许/迁白名单按钮
89
+ llmAssist: false # 先由 LLM 分类裁决;ask/无分类器时回退到人工
90
+ ```
91
+ (llmAssist 的真实接收 LLM 在设置页填 `classifierEndpoint` / `classifierModel`,OpenAI 兼容的自定义 API
92
+ 均可。)
93
+
94
+ `trustAutoAllow` 是中间档基线(rule-allow 自动放行)。`alwaysConfirm` 让每次越界都走审批面板,其
95
+ 「允许控件」含两个扩展按钮:**本会话重复允许该类**(会话限次 grant,`approveRepeat`)与
96
+ **允许所有类型**(把命令词持久写进 `permissions.yaml` 的 allow 白名单,`approveAllowEverywhere`)。
97
+ `llmAssist` 调用配置的真实 LLM(任意 OpenAI 兼容 API)自动裁决 `ask`,结果不确定/出错时回退人工
98
+ 接缝——始终 fail-closed。`permissive` 关闭时,门禁行为与之前完全一致。
99
+
100
+ ### 权限下拉里可选档位
101
+
102
+ `cordis.patch.yml` 通过扩展 DSH 的 `permission.config.presets` 加入了 `permissive` preset
103
+ (`sandbox: workspace-write`、`approval: ask`、名称 **Permissive**),位于 Workspace Write 与
104
+ Full access 之间——与 Auto 档同一机制。因此会话权限下拉里会出现 Permissive 这个**独立可选审批档**,
105
+ 而不是"auto-approval"档。
106
+
107
+ ### 在 UI 里可配置
108
+
109
+ 该档位也可在运行时从 **设置 → 插件 → Permissive 审批档** 调整(插件浏览器端渲染的
110
+ `settings.plugins.tab` 页面):一个开关切换 `permissive`,三个开关编辑后台
111
+ `permissiveStrategies`。host 端 live 读取该命名空间,改动对下一条工具调用即时生效,无需重启。
112
+ 这是一个独立审批类,**不是** DSH 的"auto-approval"档。
113
+
114
+ ### 风险分级 llmAssist、裁决学习与事件流
115
+
116
+ 开启 `llmAssist` 后,自定义 LLM(任何 OpenAI 兼容端点——把 `classifierEndpoint` /
117
+ `classifierModel` / `classifierApiKey` 指向你自己的 API)按结构化协议逐条评估 `ask`:
118
+
119
+ - `safe` → 自动放行(审计来源为 `classifier`)。
120
+ - `risky` + **硬风险类别**(`deletion`、`credential`、`remote`、`system`、`bulk`)→ 一律转人工;硬风险永不自动放行、也永不进入学习。
121
+ - `risky:neutral` → 若开启 `riskLearning`(设置卡片内,默认关闭),人工批准且真实执行的 neutral 风险会按 `tool|类别` 计数;计数达到 `riskThreshold`(默认 3)且新调用的操作指纹(命令词 + 目标基名)命中已确认样本时,**同一操作**自动放行。不同目标永不复用该放行。
122
+ - 超时(`riskTimeoutMs`,默认 20s,重试 1 次)、传输失败与协议外输出均维持原 `ask`——门禁绝不猜测。
123
+
124
+ 学习状态持久化在插件自有 JSON(`$DSH_HOME/perm-gate/learning.json` 或 `learningFile`),不写入你的 YAML 规则文件。每次决策都会追加到 `$DSH_HOME/perm-gate/events.jsonl`(或 `eventsFile`),并经 `GET /api/dsh-perm-gate/events?sessionId=&since=` 提供;浏览器端轮询该接口,在输入框上方以提示条展示最新决策(ask 常驻至下一条事件)。Permissive 档在权限选择器中保留盾形图标——菜单项与折叠触发按钮均有图标。
125
+
126
+
127
+ ## CLI(独立 dry-run)
128
+
129
+ ```sh
130
+ dsh-perm-gate --rules permissions.yaml --tool bash --args '{"command":"pnpm install"}'
131
+ dsh-perm-gate --rules permissions.yaml --list
132
+ ```
133
+
134
+ ## 开发
135
+
136
+ ```sh
137
+ npm run typecheck
138
+ npm test
139
+ npm run build
140
+ ```
141
+
142
+ ## 许可证
143
+
144
+ [MIT](./LICENSE)
File without changes
@@ -0,0 +1,5 @@
1
+ # assets
2
+
3
+ Screenshots and diagrams referenced by the README documentation. Add a `.gitkeep`
4
+ in this directory to keep it in the repository; place images (e.g. `readme/*.png`)
5
+ used by `README.md` / `README.zh.md` / `README.ja.md` / `README.ko.md` here.
@@ -0,0 +1,35 @@
1
+ # dsh-perm-gate profile patch.
2
+ #
3
+ # The DSH bundle patch REPLACES the whole `permission.config.presets` map (it
4
+ # does not merge per key), so this file MUST restate every preset that should
5
+ # survive — the built-ins AND the `auto` tier contributed by dsh-auto-mode.
6
+ # Omitting any of them silently disables that tier on the next profile load.
7
+ # 1) Add an independent "Permissive" session approval tier to the DSH permission
8
+ # picker (same mechanism Auto uses).
9
+ # 2) Insert the plugin itself (host half) into the profile.
10
+ - id: permission
11
+ config:
12
+ presets:
13
+ read-only:
14
+ sandbox: read-only
15
+ approval: ask
16
+ workspace-write:
17
+ sandbox: workspace-write
18
+ approval: ask
19
+ permissive:
20
+ sandbox: workspace-write
21
+ approval: ask
22
+ name: Permissive
23
+ description: Independent approval tier — safe in-scope ops auto-allow, dangerous crossings ask (approval strengths tunable under Settings → Plugins → Permissive approval tier).
24
+ auto:
25
+ sandbox: workspace-write
26
+ approval: ask
27
+ name: Auto
28
+ description: Workspace-sandboxed execution with automatic review and one-shot approval for wider access.
29
+ danger-full-access:
30
+ sandbox: danger-full-access
31
+ approval: never
32
+
33
+ - insert:
34
+ - id: dsh-perm-gate
35
+ name: dsh-perm-gate
@@ -0,0 +1,20 @@
1
+ permissions:
2
+ defaultAction: ask
3
+ deny:
4
+ - command: [rm#recursive, rm#force]
5
+ reason: no recursive/forced destructive deletion
6
+ - command: [sudo]
7
+ reason: no privilege escalation
8
+ - command: [ssh]
9
+ reason: no direct remote shell
10
+ - paths: [.dsh/**]
11
+ reason: protect harness metadata
12
+ allow:
13
+ - command: [git pull, pnpm, node, npm run]
14
+ reason: routine project development
15
+ - command: [curl, wget]
16
+ args: ["https://*.example.com/*"]
17
+ reason: allowed package/registry endpoint
18
+ ask:
19
+ - command: [bash, sh, pwsh]
20
+ reason: stateful or unlisted shell — semantic review
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Append one command pattern as a new `allow` entry in the rules file.
3
+ * Returns false (leaving the file untouched) on any I/O or shape error.
4
+ * @param rulesFile - absolute or relative path to the permissions YAML.
5
+ * @param pattern - command word (glob) to whitelist, e.g. `git push`.
6
+ * @param reason - human-readable reason recorded on the allow entry.
7
+ */
8
+ export declare function appendAllowCommand(rulesFile: string, pattern: string, reason?: string): boolean;
9
+ /** The allow-list command patterns currently in the rules file (read-only). */
10
+ export declare function listAllowCommands(rulesFile: string): string[];
11
+ /**
12
+ * Replace the rules file's `allow` whitelist with exactly the given command
13
+ * patterns (one allow entry per pattern). Other sections (deny / ask /
14
+ * defaultAction) are preserved. Returns false on any I/O or shape error.
15
+ * @param rulesFile - absolute or relative path to the permissions YAML.
16
+ * @param patterns - exact command-pattern list to set; empty clears the whitelist.
17
+ * @param reason - reason recorded on each generated allow entry.
18
+ */
19
+ export declare function replaceAllowCommands(rulesFile: string, patterns: readonly string[], reason?: string): boolean;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Durable allowlist (whitelist) writes for dsh-perm-gate.
3
+ *
4
+ * The "allow everywhere" approval action appends a matched command pattern to
5
+ * the rules file's `allow` list and reloads, so a human-approved command is
6
+ * persistently whitelisted across sessions — not just granted once.
7
+ */
8
+ import { readFileSync, writeFileSync } from 'node:fs';
9
+ import { parse, stringify } from 'yaml';
10
+ /**
11
+ * Append one command pattern as a new `allow` entry in the rules file.
12
+ * Returns false (leaving the file untouched) on any I/O or shape error.
13
+ * @param rulesFile - absolute or relative path to the permissions YAML.
14
+ * @param pattern - command word (glob) to whitelist, e.g. `git push`.
15
+ * @param reason - human-readable reason recorded on the allow entry.
16
+ */
17
+ export function appendAllowCommand(rulesFile, pattern, reason = 'permissive allow-everywhere') {
18
+ try {
19
+ const text = readFileSafe(rulesFile);
20
+ const root = text === '' ? undefined : parse(text);
21
+ const doc = root ?? {};
22
+ const perms = doc['permissions'];
23
+ const section = (perms === undefined ? doc : perms);
24
+ if (typeof section !== 'object' || section === null || Array.isArray(section))
25
+ return false;
26
+ const allow = Array.isArray(section['allow']) ? section['allow'] : [];
27
+ allow.push({ command: [pattern], reason });
28
+ section['allow'] = allow;
29
+ writeFileSync(rulesFile, stringify(doc), 'utf8');
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ /** The allow-list command patterns currently in the rules file (read-only). */
37
+ export function listAllowCommands(rulesFile) {
38
+ try {
39
+ const text = readFileSafe(rulesFile);
40
+ if (text === '')
41
+ return [];
42
+ const root = parse(text);
43
+ const perms = root['permissions'];
44
+ const section = perms === undefined ? root : perms;
45
+ const allow = Array.isArray(section['allow']) ? section['allow'] : [];
46
+ const out = [];
47
+ for (const entry of allow) {
48
+ if (typeof entry !== 'object' || entry === null)
49
+ continue;
50
+ const command = entry['command'];
51
+ if (!Array.isArray(command))
52
+ continue;
53
+ for (const c of command)
54
+ if (typeof c === 'string')
55
+ out.push(c);
56
+ }
57
+ return out;
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ }
63
+ /**
64
+ * Replace the rules file's `allow` whitelist with exactly the given command
65
+ * patterns (one allow entry per pattern). Other sections (deny / ask /
66
+ * defaultAction) are preserved. Returns false on any I/O or shape error.
67
+ * @param rulesFile - absolute or relative path to the permissions YAML.
68
+ * @param patterns - exact command-pattern list to set; empty clears the whitelist.
69
+ * @param reason - reason recorded on each generated allow entry.
70
+ */
71
+ export function replaceAllowCommands(rulesFile, patterns, reason = 'permissive allowlist') {
72
+ try {
73
+ if (rulesFile === '')
74
+ return false;
75
+ const text = readFileSafe(rulesFile);
76
+ const root = text === '' ? undefined : parse(text);
77
+ const doc = root ?? {};
78
+ const perms = doc['permissions'];
79
+ const section = (perms === undefined ? doc : perms);
80
+ if (typeof section !== 'object' || section === null || Array.isArray(section))
81
+ return false;
82
+ section['allow'] = patterns.map((pattern) => ({ command: [pattern], reason }));
83
+ writeFileSync(rulesFile, stringify(doc), 'utf8');
84
+ return true;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ function readFileSafe(p) {
91
+ try {
92
+ return readFileSync(p, 'utf8');
93
+ }
94
+ catch {
95
+ return '';
96
+ }
97
+ }
package/lib/audit.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Audit for dsh-perm-gate: a typed, ignorable-marked event log plus the
3
+ * invariant that whatever is model-visible (the reason text surfaced into the
4
+ * tool result) is ALSO recorded with the same `callId`/`outcome`.
5
+ *
6
+ * The `ignorable` marker is what lets hosts treat these events as log-only,
7
+ * never re-injected into the model context. Hosts that predate the marker
8
+ * (return value of the append proves it) make the runtime degrade to an
9
+ * in-memory mirror instead of failing.
10
+ */
11
+ export type AuditOutcome = 'allow' | 'deny' | 'ask' | 'delegated';
12
+ export type DecisionSource = 'hard-deny' | 'grant' | 'rule' | 'default' | 'ask' | 'classifier' | 'permissive';
13
+ export interface AuditEntry {
14
+ readonly kind: 'permissionGate/decision';
15
+ readonly marker: 'ignorable';
16
+ readonly callId: string;
17
+ readonly tool: string;
18
+ readonly outcome: AuditOutcome;
19
+ readonly source: DecisionSource;
20
+ readonly reason: string;
21
+ readonly at: number;
22
+ }
23
+ export interface AuditSink {
24
+ append(entry: AuditEntry): {
25
+ markerSupported: boolean;
26
+ };
27
+ }
28
+ /** The on-disk/on-host append contract a DSH session `append` should honor. */
29
+ export declare class MemoryAuditMirror implements AuditSink {
30
+ readonly entries: AuditEntry[];
31
+ append(entry: AuditEntry): {
32
+ markerSupported: boolean;
33
+ };
34
+ count(kind?: AuditOutcome): number;
35
+ }
36
+ /** The invariant pairing the model-visible reason with its recorded event. */
37
+ export declare function assertInvariant(visible: {
38
+ callId: string;
39
+ } | undefined, entry: AuditEntry): boolean;
40
+ /**
41
+ * Detect whether a host honors the `ignorable` marker before the first append,
42
+ * by probing the append surface's return value.
43
+ */
44
+ export declare function probeHost(append: (entry: AuditEntry) => unknown): boolean;
45
+ export declare function makeEntry(input: Omit<AuditEntry, 'kind' | 'marker'>): AuditEntry;
package/lib/audit.js ADDED
@@ -0,0 +1,34 @@
1
+ /** The on-disk/on-host append contract a DSH session `append` should honor. */
2
+ export class MemoryAuditMirror {
3
+ entries = [];
4
+ append(entry) {
5
+ this.entries.push(entry);
6
+ return { markerSupported: true };
7
+ }
8
+ count(kind) {
9
+ return kind === undefined ? this.entries.length : this.entries.filter((e) => e.outcome === kind).length;
10
+ }
11
+ }
12
+ /** The invariant pairing the model-visible reason with its recorded event. */
13
+ export function assertInvariant(visible, entry) {
14
+ // The visible reason must carry the same callId that was logged; otherwise a
15
+ // model-visible decision could exist with no audit trace.
16
+ return visible === undefined || visible.callId === entry.callId;
17
+ }
18
+ /**
19
+ * Detect whether a host honors the `ignorable` marker before the first append,
20
+ * by probing the append surface's return value.
21
+ */
22
+ export function probeHost(append) {
23
+ try {
24
+ const probe = append({ kind: 'permissionGate/decision', marker: 'ignorable', callId: '__probe__', tool: '__probe__', outcome: 'ask', source: 'ask', reason: 'probe', at: Date.now() });
25
+ const boxed = probe;
26
+ return boxed.markerSupported !== false;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ export function makeEntry(input) {
33
+ return { kind: 'permissionGate/decision', marker: 'ignorable', ...input };
34
+ }