@dsh-cc/permission-rules 0.5.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 (65) hide show
  1. package/LICENSE +201 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +86 -0
  4. package/README.zh.md +86 -0
  5. package/lib/approval-listener.d.ts +50 -0
  6. package/lib/approval-listener.d.ts.map +1 -0
  7. package/lib/approval-listener.js +58 -0
  8. package/lib/approval-listener.js.map +1 -0
  9. package/lib/auto-stage.d.ts +138 -0
  10. package/lib/auto-stage.d.ts.map +1 -0
  11. package/lib/auto-stage.js +284 -0
  12. package/lib/auto-stage.js.map +1 -0
  13. package/lib/classifier.d.ts +57 -0
  14. package/lib/classifier.d.ts.map +1 -0
  15. package/lib/classifier.js +129 -0
  16. package/lib/classifier.js.map +1 -0
  17. package/lib/decide.d.ts +80 -0
  18. package/lib/decide.d.ts.map +1 -0
  19. package/lib/decide.js +127 -0
  20. package/lib/decide.js.map +1 -0
  21. package/lib/domain.d.ts +46 -0
  22. package/lib/domain.d.ts.map +1 -0
  23. package/lib/domain.js +103 -0
  24. package/lib/domain.js.map +1 -0
  25. package/lib/evaluate.d.ts +32 -0
  26. package/lib/evaluate.d.ts.map +1 -0
  27. package/lib/evaluate.js +176 -0
  28. package/lib/evaluate.js.map +1 -0
  29. package/lib/index.d.ts +123 -0
  30. package/lib/index.d.ts.map +1 -0
  31. package/lib/index.js +380 -0
  32. package/lib/index.js.map +1 -0
  33. package/lib/invariant.d.ts +28 -0
  34. package/lib/invariant.d.ts.map +1 -0
  35. package/lib/invariant.js +54 -0
  36. package/lib/invariant.js.map +1 -0
  37. package/lib/llm-classifier.d.ts +107 -0
  38. package/lib/llm-classifier.d.ts.map +1 -0
  39. package/lib/llm-classifier.js +231 -0
  40. package/lib/llm-classifier.js.map +1 -0
  41. package/lib/matchers.d.ts +18 -0
  42. package/lib/matchers.d.ts.map +1 -0
  43. package/lib/matchers.js +43 -0
  44. package/lib/matchers.js.map +1 -0
  45. package/lib/mode.d.ts +91 -0
  46. package/lib/mode.d.ts.map +1 -0
  47. package/lib/mode.js +133 -0
  48. package/lib/mode.js.map +1 -0
  49. package/lib/parser.d.ts +91 -0
  50. package/lib/parser.d.ts.map +1 -0
  51. package/lib/parser.js +282 -0
  52. package/lib/parser.js.map +1 -0
  53. package/lib/session-allowlist.d.ts +76 -0
  54. package/lib/session-allowlist.d.ts.map +1 -0
  55. package/lib/session-allowlist.js +122 -0
  56. package/lib/session-allowlist.js.map +1 -0
  57. package/lib/settings-schema.d.ts +99 -0
  58. package/lib/settings-schema.d.ts.map +1 -0
  59. package/lib/settings-schema.js +64 -0
  60. package/lib/settings-schema.js.map +1 -0
  61. package/lib/types.d.ts +150 -0
  62. package/lib/types.d.ts.map +1 -0
  63. package/lib/types.js +33 -0
  64. package/lib/types.js.map +1 -0
  65. package/package.json +71 -0
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/interaction/permission-rules/README.md
5
+ README.md: 61fa514bd8c7b7a45c9c7f2502c173fcb83a9a2a
6
+ README.zh.md: cd5e4eed56f8e29aa72e5ee811b9a304b6a04031
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @dsh-cc/permission-rules
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ Claude Code-compatible permission-rule engine. Parses `ToolName` and `ToolName(content)` rules, folds a mode-aware decision on the `tools/pre-execute` waterfall, and enforces bypass-immune content rules through the monotonic `guard()` layer so neither a mode switch nor `bypassPermissions` can override them. Rules fail loud at load; settings hot-reload by rebuilding merged state and re-registering guards.
6
+
7
+ ## Rule syntax
8
+
9
+ A rule is `ToolName` (whole tool) or `ToolName(content)` (content-scoped). `content` may escape `(`/`)`/`\` with a backslash, use `*` as a wildcard, or end in `:*` to declare a prefix rule.
10
+
11
+ | Rule | Meaning |
12
+ |---|---|
13
+ | `Bash` | whole-tool rule for every `Bash` call |
14
+ | `Bash(npm install)` | prefix rule: any command starting with `npm install` |
15
+ | `Bash(npm publish:*)` | prefix rule on the stem `npm publish:` |
16
+ | `Edit(foo/*.json)` | wildcard: commands/paths matching `foo/*.json` (a `*` matches any run) |
17
+ | `Bash(python -c "print\(1\)")` | literal parens inside content |
18
+
19
+ Malformed rules (unclosed paren, content after the closing paren, content with no tool name) throw a `TypeError` at load — fail loud. `escapeRuleContent`/`unescapeRuleContent` round-trip content safely (`\` first, then parens).
20
+
21
+ ## Evaluation order
22
+
23
+ The plugin registers a `tools/pre-execute` listener and folds one decision per call:
24
+
25
+ 1. **Bypass-immune** content rules (e.g. `.git` internals, shell-config paths) always deny — registered as monotonic **guards**, never overridable by a mode switch or `bypassPermissions`.
26
+ 2. **Risk classifier** (when `classifierEnabled`, default on): catastrophic shell commands (`rm -rf /`, `sudo`, `dd of=/dev`, `kill -9 1`, piping curl/wget into sh, redirecting into system paths) are a hard **deny in every mode**; writes to protected files (`.bashrc`, `.ssh/**`, credentials) are also hard denies; writes that escape the working directory scope are **ask** outside `bypassPermissions` (allowed under it).
27
+ 3. **whole-tool deny** → deny.
28
+ 4. **whole-tool ask** → ask (a sandboxed, confining `Bash` is exempt and allowed instead when `exemptSandboxedBashFromToolAsk` is set).
29
+ 5. **content-level allow/deny/ask** rules by source priority (highest source first; first rule to match decides).
30
+ 6. **mode** short-circuits: `bypassPermissions` allows everything (unless `disableBypassPermissionsMode`); `acceptEdits` auto-allows file-edit tools; `plan` auto-allows read-only tools. `auto` is not an evaluate short-circuit — it evaluates identically to `default`, with the risk classifier proxying asks at the plugin layer.
31
+ 7. **whole-tool allow** is the coarse default for that tool when nothing more specific matched.
32
+ 8. **no match** → passthrough to downstream listeners (ultimately the approval seam), which may still `ask`.
33
+ 9. **plan wrap**: leftover `ask`/`passthrough` on a non-read-only call becomes a deny with `plan mode is read-only; submit via exit_plan_mode`. Matching allow/deny rules still stand.
34
+
35
+ ## Config
36
+
37
+ ```ts
38
+ import PermissionRules from '@dsh-cc/permission-rules'
39
+
40
+ await ctx.plugin(PermissionRules, {
41
+ rules: {
42
+ deny: ['Bash(rm -rf)', 'Edit(.git*)'],
43
+ bypassImmune: ['Edit(~/.bashrc)', 'Edit(~/.zshrc)'],
44
+ },
45
+ bashToolName: 'Bash', // default
46
+ fileEditTools: ['edit'], // auto-allowed under acceptEdits
47
+ readOnlyTools: ['read'], // auto-allowed under plan
48
+ exemptSandboxedBashFromToolAsk: false,
49
+ defaultMode: 'default',
50
+ classifierEnabled: true, // risk-classifier escalation stage
51
+ })
52
+ ```
53
+
54
+ All fields are optional; the service schema applies the illustrated defaults. Rule strings are parsed with source `config`.
55
+
56
+ ## Settings and hot reload
57
+
58
+ When `ctx.settings` is mounted, the plugin registers the `permissions` namespace (`permissions.allow` / `permissions.deny` / `permissions.ask` / `permissions.defaultMode`, plus `additionalDirectories` / `protectedFiles` / `dangerousPatterns` feeding the risk classifier, and the optional `autoMode` section — `autoMode.soft_deny` prose rules (with `$defaults` expansion) and `autoMode.classifier` (`enabled` / `route` / `timeoutMs` / `cacheMaxEntries`) arming the opt-in LLM risk-classifier stage for `auto` mode; an absent `autoMode` key stays absent, so the stage remains disarmed). Settings rules carry the `settingsSource` label (default `userSettings`) and merge with Config `rules` by source priority — settings rules win. A stored change re-runs the merge and re-registers guards immediately (hot reload); a malformed settings rule fails loud at the settings boundary. When `ctx.settings` is absent, only the Config `rules` are in force (the classifier uses its curated defaults).
59
+
60
+ ## Sources and modes
61
+
62
+ Every rule carries a `PermissionRuleSource` (`session` > `cliArg` > `policySettings` > `flagSettings` > `localSettings` > `projectSettings` > `userSettings` > `config`) used for content-rule priority. The engine resolves the effective mode at call time: plan activation (from `@deepseek-ai/dsh-plan-mode`) overlays first, then the session's recorded `permission/mode` override (`foldPermissionMode`), falling back to `defaultMode`.
63
+
64
+ Modes are **durable** — `setMode(agent, mode)` appends a last-wins `permission/mode` session event (registered into `KNOWN_SESSION_EVENT_TYPES` at plugin load so persistence resumes it). `plan` is owned by plan-mode and throws here. Entering `bypassPermissions` pins the session sandbox to `danger-full-access` and records `resumeSandbox`; leaving restores the recorded (or `workspace-write` fallback) confinement. Under `auto`, the risk classifier proxies every `ask`: classifier-LOW calls auto-allow, classifier-MEDIUM still asks. When the LLM classifier stage is armed, read-only tool calls are exempt — they never reach the model (zero added latency on read traffic). Verdict parsing is strict and fail-closed: a malformed model output yields the constant reason `classifier output unparseable` (model output is never shown; audit records are digest-only). A per-route consecutive-failure circuit breaker (threshold 3, keyed `provider/model`) opens the stage for a failing lane — no further classifier calls on that route, one warn per process, one `breaker` audit event per session; `rebuild()` (a settings change) resets the breaker state and re-arms.
65
+
66
+ ## Switching modes
67
+
68
+ `permissionRules.setMode(agent, mode)` switches durably (see above); the `/permissions <mode>` command (in `@dsh-cc/command-permissions`) drives it for `default | acceptEdits | plan | auto | bypassPermissions`. A human-facing notice is injected into the session's model transcript on each switch.
69
+
70
+ ## Pure exports for host UI
71
+
72
+ - `parseRuleString(rule)`, `parseRule(rule, behavior, source)`, `escapeRuleContent`/`unescapeRuleContent` — parse rules to `PermissionRule`.
73
+ - `evaluatePermission(input)` — fold a `PermissionDecision` for a call (`allow` / `deny` / `ask` / `passthrough`) given tool, subject, rule set, mode, and exemption flags. Use it to preview what a rule hits without mounting the plugin.
74
+ - `mergeRuleSets(...sets)` — merge rule sets by source priority.
75
+ - `foldPermissionMode(events)`, `foldResumeSandbox(events)`, `setPermissionMode(session, mode, resumeSandbox?)` — read/write the durable `permission/mode` override. `setPermissionMode` rejects `plan` and unknown modes; other plugins can fold a session's recorded mode via `foldPermissionMode`.
76
+ - `assessBashCommand(command, patterns?)` — risk-classify a shell command (`LOW`/`HIGH`).
77
+ - `assessFilePath(filePath, opts)` — risk-classify a file write (`LOW`/`MEDIUM`/`HIGH`).
78
+ - `PERMISSION_MODES`, `SOURCE_PRIORITY` — closed vocabularies.
79
+
80
+ Rule parsing and evaluation are browser-safe (pure string logic), so the type/parser/evaluate modules import cleanly into UI previews.
81
+
82
+ ## Invariant companion
83
+
84
+ `@dsh-cc/permission-rules/invariant` validates `permission/mode` session events at the session boundary: `mode` must be switchable (never `plan`), and `resumeSandbox` — when present — must be a known sandbox mode (`read-only` | `workspace-write` | `danger-full-access`).
85
+
86
+ See the [Agent Note](../../../.agents/notes/implemented/feature/2026-08-14-cc-permission-rules.md).
package/README.zh.md ADDED
@@ -0,0 +1,86 @@
1
+ # @dsh-cc/permission-rules
2
+
3
+ [English](README.md) | 中文
4
+
5
+ Claude Code 兼容的权限规则引擎。解析 `ToolName` 与 `ToolName(content)` 规则,在 `tools/pre-execute` waterfall 上收敛出感知模式的判定,并通过单调的 `guard()` 层强制执行 bypass-immune 内容规则——任何模式切换或 `bypassPermissions` 都无法翻盘。规则在加载期 fail loud;settings 通过重建合并状态并重注册 guard 实现热更新。
6
+
7
+ ## 规则语法
8
+
9
+ 规则形如 `ToolName`(整工具)或 `ToolName(content)`(内容级)。`content` 可用反斜杠转义 `(`/`)`/`\`,可用 `*` 作为通配符,也可用 `:*` 结尾声明前缀规则。
10
+
11
+ | 规则 | 含义 |
12
+ |---|---|
13
+ | `Bash` | 覆盖所有 `Bash` 调用的整工具规则 |
14
+ | `Bash(npm install)` | 前缀规则:任何以 `npm install` 开头的命令 |
15
+ | `Bash(npm publish:*)` | 对主干 `npm publish:` 的前缀规则 |
16
+ | `Edit(foo/*.json)` | 通配符:匹配 `foo/*.json` 的命令/路径(`*` 匹配任意片段) |
17
+ | `Bash(python -c "print\(1\)")` | 内容中的字面括号 |
18
+
19
+ 畸形规则(括号未闭合、结束括号后有内容、只有内容没有工具名)在加载期抛出 `TypeError`——fail loud。`escapeRuleContent`/`unescapeRuleContent` 可安全往返内容(先 `\`,再括号)。
20
+
21
+ ## 评估顺序
22
+
23
+ 插件注册一个 `tools/pre-execute` 监听器,为每次调用收敛一个判定:
24
+
25
+ 1. **Bypass-immune** 内容规则(例如 `.git` 内部、shell 配置文件路径)始终 deny——注册为单调 **guard**,模式切换或 `bypassPermissions` 都不能覆盖。
26
+ 2. **风险分类器**(`classifierEnabled` 开启时,默认开):灾难性 shell 命令(`rm -rf /`、`sudo`、`dd of=/dev`、`kill -9 1`、将 curl/wget 管道接入 sh、重定向到系统路径)在所有模式下**硬 deny**;写入受保护文件(`.bashrc`、`.ssh/**`、凭据)同样硬 deny;写文件逃逸出工作目录作用域时在 `bypassPermissions` 之外 **ask**(在它之下放行)。
27
+ 3. **整工具 deny** → deny。
28
+ 4. **整工具 ask** → ask(当设置了 `exemptSandboxedBashFromToolAsk` 时,被沙箱限制的 `Bash` 豁免并直接 allow)。
29
+ 5. **内容级 allow/deny/ask** 规则按来源优先级评估(最高优先级先;首个命中规则决定)。
30
+ 6. **模式**短路:`bypassPermissions` 放行一切(除非 `disableBypassPermissionsMode`);`acceptEdits` 自动放行文件编辑工具;`plan` 自动放行只读工具。`auto` 不是 evaluate 短路——它与 `default` 评估一致,审批由插件层的风险分类器代理。
31
+ 7. **整工具 allow** 是该工具的粗略默认——没有更具体的规则命中时放行。
32
+ 8. **无命中** → passthrough 给下游监听器(最终到审批缝),后者仍可能 `ask`。
33
+ 9. **plan 收口**:非只读调用上残留的 `ask`/`passthrough` 变成 deny,理由是 `plan mode is read-only; submit via exit_plan_mode`。命中的 allow/deny 规则仍然生效。
34
+
35
+ ## 配置
36
+
37
+ ```ts
38
+ import PermissionRules from '@dsh-cc/permission-rules'
39
+
40
+ await ctx.plugin(PermissionRules, {
41
+ rules: {
42
+ deny: ['Bash(rm -rf)', 'Edit(.git*)'],
43
+ bypassImmune: ['Edit(~/.bashrc)', 'Edit(~/.zshrc)'],
44
+ },
45
+ bashToolName: 'Bash', // default
46
+ fileEditTools: ['edit'], // auto-allowed under acceptEdits
47
+ readOnlyTools: ['read'], // auto-allowed under plan
48
+ exemptSandboxedBashFromToolAsk: false,
49
+ defaultMode: 'default',
50
+ classifierEnabled: true, // 风险分类器升级阶段
51
+ })
52
+ ```
53
+
54
+ 所有字段可选;服务 schema 应用图示默认值。规则字符串以 `config` 来源解析。
55
+
56
+ ## settings 与热更新
57
+
58
+ 当 `ctx.settings` 挂载时,插件注册 `permissions` 命名空间(`permissions.allow` / `permissions.deny` / `permissions.ask` / `permissions.defaultMode`,另加供风险分类器使用的 `additionalDirectories` / `protectedFiles` / `dangerousPatterns`,以及可选的 `autoMode` 小节——`autoMode.soft_deny` 文字规则(支持 `$defaults` 展开)与 `autoMode.classifier`(`enabled` / `route` / `timeoutMs` / `cacheMaxEntries`)共同驱动 `auto` 模式下可选择性开启的 LLM 风险分类器阶段;`autoMode` 键缺席即保持缺席,该阶段保持解除武装)。settings 规则携带 `settingsSource` 标签(默认 `userSettings`),并按来源优先级与 Config `rules` 合并——settings 规则优先。存储变更会立即重跑合并并重注册 guard(热更新);畸形 settings 规则在 settings 边界 fail loud。当 `ctx.settings` 缺席时,仅 Config `rules` 生效(分类器使用其精选默认值)。
59
+
60
+ ## 来源与模式
61
+
62
+ 每条规则携带 `PermissionRuleSource`(`session` > `cliArg` > `policySettings` > `flagSettings` > `localSettings` > `projectSettings` > `userSettings` > `config`),用于内容规则的优先级。引擎在调用时解析生效模式:plan 激活(来自 `@deepseek-ai/dsh-plan-mode`)最先覆盖,然后是会话记录的 `permission/mode` 覆盖(`foldPermissionMode`),否则回退到 `defaultMode`。
63
+
64
+ 模式是**持久的**——`setMode(agent, mode)` 追加一条 last-wins 的 `permission/mode` 会话事件(插件加载时注册进 `KNOWN_SESSION_EVENT_TYPES`,持久化可恢复它)。`plan` 归 plan-mode 所有,在这里会抛错。进入 `bypassPermissions` 会把会话沙箱钉到 `danger-full-access` 并记录 `resumeSandbox`;离开时恢复记录(或回退 `workspace-write`)的约束。在 `auto` 下,风险分类器代理每次 `ask`:LOW 风险调用自动放行,MEDIUM 风险仍会询问。LLM 分类器阶段武装时,只读工具调用豁免——完全不经过模型(读流量零额外延迟)。判定解析保持严格并 fail-closed:模型输出畸形时返回常量原因 `classifier output unparseable`(模型输出永不展示;审计记录只含摘要)。按路由的连续失败熔断器(阈值 3,按 `provider/model` 键控)会为故障车道打开断路——该路由不再调用分类器,每进程一次 warn、每会话一条 `breaker` 审计事件;`rebuild()`(设置变更)重置熔断状态并重新武装。
65
+
66
+ ## 切换模式
67
+
68
+ `permissionRules.setMode(agent, mode)` 持久切换(见上);`/permissions <mode>` 命令(在 `@dsh-cc/command-permissions`)为 `default | acceptEdits | plan | auto | bypassPermissions` 驱动它。每次切换都会向会话的模型转录注入一条面向人的提示。
69
+
70
+ ## 供宿主 UI 使用的纯导出
71
+
72
+ - `parseRuleString(rule)`、`parseRule(rule, behavior, source)`、`escapeRuleContent`/`unescapeRuleContent`——解析规则为 `PermissionRule`。
73
+ - `evaluatePermission(input)`——为一次调用收敛 `PermissionDecision`(`allow` / `deny` / `ask` / `passthrough`),给定工具、subject、规则集、模式与豁免标志。无需挂载插件即可预览某规则会命中什么。
74
+ - `mergeRuleSets(...sets)`——按来源优先级合并规则集。
75
+ - `foldPermissionMode(events)`、`foldResumeSandbox(events)`、`setPermissionMode(session, mode, resumeSandbox?)`——读写持久的 `permission/mode` 覆盖。`setPermissionMode` 拒绝 `plan` 与未知模式;其他插件可用 `foldPermissionMode` 折叠某会话的记录模式。
76
+ - `assessBashCommand(command, patterns?)`——对 shell 命令做风险分级(`LOW`/`HIGH`)。
77
+ - `assessFilePath(filePath, opts)`——对文件写入做风险分级(`LOW`/`MEDIUM`/`HIGH`)。
78
+ - `PERMISSION_MODES`、`SOURCE_PRIORITY`——封闭词汇表。
79
+
80
+ 规则解析与评估是浏览器安全的(纯字符串逻辑),因此类型/解析/评估模块可干净地导入 UI 预览。
81
+
82
+ ## Invariant 伴生插件
83
+
84
+ `@dsh-cc/permission-rules/invariant` 在会话边界校验 `permission/mode` 会话事件:`mode` 必须是可切换的(绝不能是 `plan`),且 `resumeSandbox`——若存在——必须是已知沙箱模式(`read-only` | `workspace-write` | `danger-full-access`)。
85
+
86
+ 参见 [Agent Note](../../../.agents/notes/implemented/feature/2026-08-14-cc-permission-rules.md)。
@@ -0,0 +1,50 @@
1
+ /**
2
+ * WS3 approval-seam listener: auto-approves sandbox escalations in `auto`
3
+ * mode when the operation is attributable to the session workspace.
4
+ *
5
+ * WS0 verified that sandbox escalation prompts travel a separate
6
+ * `approval/request` seam, independent of permission modes — so a mode-based
7
+ * override cannot suppress them. This listener registers on that seam's
8
+ * waterfall (before any UI listener) and resolves the request itself when
9
+ * ALL of the following hold:
10
+ *
11
+ * 1. the request carries a reason identifying it as a sandbox escalation
12
+ * ({@link isSandboxEscalation});
13
+ * 2. the effective permission mode for the requesting session is `auto`;
14
+ * 3. the session has a resolvable workspace root (the per-call sandbox policy
15
+ * is derived from the session cwd — WS3 — so a known workspace root is
16
+ * exactly the boundary the escalation was evaluated against).
17
+ *
18
+ * Otherwise the request falls through to `next()` and the normal approval
19
+ * flow proceeds. Every auto-approval is audit-logged to the session log as a
20
+ * `permission/session-allow` record with `scope: 'sandbox-auto'`.
21
+ *
22
+ * @module @dsh-cc/permission-rules/approval-listener
23
+ */
24
+ import type { Agent } from '@deepseek-ai/dsh-agent';
25
+ import type { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-user-approval';
26
+ import type { PermissionMode } from './types.ts';
27
+ /** Seams the listener reads off its configuring context; all injectable for tests. */
28
+ export interface SandboxApprovalListenerConfig {
29
+ /** The effective permission mode for the requesting session. */
30
+ modeOf(agent: Agent): PermissionMode;
31
+ /**
32
+ * The session's workspace root, or undefined when none is known. An
33
+ * undefined workspace root can never be auto-approved (the listener cannot
34
+ * verify the operation is in scope) and falls through to the next provider.
35
+ */
36
+ workspaceOf(agent: Agent): string | undefined;
37
+ }
38
+ /**
39
+ * Whether an approval request's reason identifies a sandbox escalation.
40
+ * Case-insensitive substring match on `sandbox` — the escalation reasons are
41
+ * produced by the sandbox runtime and always name the mechanism.
42
+ */
43
+ export declare function isSandboxEscalation(reason: string | undefined): boolean;
44
+ /**
45
+ * Build the `approval/request` waterfall listener. Register the returned
46
+ * listener ahead of the UI provider so an eligible sandbox escalation never
47
+ * reaches the modal queue.
48
+ */
49
+ export declare function createSandboxApprovalListener(config: SandboxApprovalListenerConfig): (req: ApprovalRequest, next: () => Promise<ApprovalOutcome>) => Promise<ApprovalOutcome>;
50
+ //# sourceMappingURL=approval-listener.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approval-listener.d.ts","sourceRoot":"","sources":["../src/approval-listener.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAA;AACnD,OAAO,KAAK,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAA;AAEtF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAEhD,sFAAsF;AACtF,MAAM,WAAW,6BAA6B;IAC5C,gEAAgE;IAChE,MAAM,CAAC,KAAK,EAAE,KAAK,GAAG,cAAc,CAAA;IACpC;;;;OAIG;IACH,WAAW,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,GAAG,SAAS,CAAA;CAC9C;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAEvE;AAED;;;;GAIG;AACH,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,6BAA6B,GAAG,CACpF,GAAG,EAAE,eAAe,EACpB,IAAI,EAAE,MAAM,OAAO,CAAC,eAAe,CAAC,KACjC,OAAO,CAAC,eAAe,CAAC,CAe5B"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * WS3 approval-seam listener: auto-approves sandbox escalations in `auto`
3
+ * mode when the operation is attributable to the session workspace.
4
+ *
5
+ * WS0 verified that sandbox escalation prompts travel a separate
6
+ * `approval/request` seam, independent of permission modes — so a mode-based
7
+ * override cannot suppress them. This listener registers on that seam's
8
+ * waterfall (before any UI listener) and resolves the request itself when
9
+ * ALL of the following hold:
10
+ *
11
+ * 1. the request carries a reason identifying it as a sandbox escalation
12
+ * ({@link isSandboxEscalation});
13
+ * 2. the effective permission mode for the requesting session is `auto`;
14
+ * 3. the session has a resolvable workspace root (the per-call sandbox policy
15
+ * is derived from the session cwd — WS3 — so a known workspace root is
16
+ * exactly the boundary the escalation was evaluated against).
17
+ *
18
+ * Otherwise the request falls through to `next()` and the normal approval
19
+ * flow proceeds. Every auto-approval is audit-logged to the session log as a
20
+ * `permission/session-allow` record with `scope: 'sandbox-auto'`.
21
+ *
22
+ * @module @dsh-cc/permission-rules/approval-listener
23
+ */
24
+ import { appendSessionAllow } from "./session-allowlist.js";
25
+ /**
26
+ * Whether an approval request's reason identifies a sandbox escalation.
27
+ * Case-insensitive substring match on `sandbox` — the escalation reasons are
28
+ * produced by the sandbox runtime and always name the mechanism.
29
+ */
30
+ export function isSandboxEscalation(reason) {
31
+ return reason !== undefined && /sandbox/i.test(reason);
32
+ }
33
+ /**
34
+ * Build the `approval/request` waterfall listener. Register the returned
35
+ * listener ahead of the UI provider so an eligible sandbox escalation never
36
+ * reaches the modal queue.
37
+ */
38
+ export function createSandboxApprovalListener(config) {
39
+ return async (req, next) => {
40
+ if (!isSandboxEscalation(req.reason))
41
+ return next();
42
+ const agent = req.agent;
43
+ if (agent === undefined)
44
+ return next();
45
+ if (config.modeOf(agent) !== 'auto')
46
+ return next();
47
+ if (config.workspaceOf(agent) === undefined)
48
+ return next();
49
+ appendSessionAllow(agent.session, {
50
+ scope: 'sandbox-auto',
51
+ toolName: req.toolName,
52
+ timestamp: Date.now(),
53
+ ...req.reason === undefined ? {} : { reason: req.reason },
54
+ });
55
+ return 'allowed-once';
56
+ };
57
+ }
58
+ //# sourceMappingURL=approval-listener.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approval-listener.js","sourceRoot":"","sources":["../src/approval-listener.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAIH,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAe3D;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAA0B;IAC5D,OAAO,MAAM,KAAK,SAAS,IAAI,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;AACxD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,6BAA6B,CAAC,MAAqC;IAIjF,OAAO,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QACzB,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,MAAM,CAAC;YAAE,OAAO,IAAI,EAAE,CAAA;QACnD,MAAM,KAAK,GAAG,GAAG,CAAC,KAAK,CAAA;QACvB,IAAI,KAAK,KAAK,SAAS;YAAE,OAAO,IAAI,EAAE,CAAA;QACtC,IAAI,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,MAAM;YAAE,OAAO,IAAI,EAAE,CAAA;QAClD,IAAI,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC,KAAK,SAAS;YAAE,OAAO,IAAI,EAAE,CAAA;QAC1D,kBAAkB,CAAC,KAAK,CAAC,OAAO,EAAE;YAChC,KAAK,EAAE,cAAc;YACrB,QAAQ,EAAE,GAAG,CAAC,QAAQ;YACtB,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE;YACrB,GAAG,GAAG,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE;SAC1D,CAAC,CAAA;QACF,OAAO,cAAc,CAAA;IACvB,CAAC,CAAA;AACH,CAAC"}
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The async LLM-classifier stage for `auto` mode: owns arming (per call, from
3
+ * the live `permissions.autoMode` settings slice), the memoized
4
+ * `createLlmClassifier` instance, and the `permission/classifier` session
5
+ * audit event. The plugin's `tools/pre-execute` listener (index.ts) only
6
+ * wires this stage — the escalate-only decision flow lives here.
7
+ *
8
+ * Arming predicate (§4.4): `autoMode.classifier.enabled === true` AND an llm
9
+ * stream capability is wired AND the alias route resolves. Enabled but
10
+ * unarmable ⇒ disarm with ONE warning per process (plus an `unarmed` audit
11
+ * event when a session is available) and the legacy decision path runs.
12
+ *
13
+ * @module @dsh-cc/permission-rules/auto-stage
14
+ */
15
+ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session';
16
+ import type { ToolExecution } from '@dsh-cc/tools';
17
+ import type { DecidedCall } from './decide.ts';
18
+ /** `permissions.autoMode.classifier` — the plugin-local hand-mirror of the shared AutoModeClassifierSchema. */
19
+ export interface AutoModeClassifierSettings {
20
+ /** Master switch for the LLM risk classifier stage (default `false`). */
21
+ enabled?: boolean;
22
+ /** Model route used for classification (default `'haiku'`). */
23
+ route?: string;
24
+ /** Per-call timeout in milliseconds (default `8000`). */
25
+ timeoutMs?: number;
26
+ /** Verdict cache size in entries (default `256`). */
27
+ cacheMaxEntries?: number;
28
+ }
29
+ /** `permissions.autoMode` — the plugin-local hand-mirror of the shared AutoModeSchema. */
30
+ export interface AutoModeSettings {
31
+ /**
32
+ * Soft-deny hints evaluated by the classifier, in CC's snake_case spelling.
33
+ * `$defaults` expansion happens at consumption time — the schema never
34
+ * expands it.
35
+ */
36
+ soft_deny?: string[];
37
+ /** LLM risk classifier configuration; absent when the section omits it. */
38
+ classifier?: AutoModeClassifierSettings;
39
+ }
40
+ /** The session event type carrying one classifier verdict audit record. */
41
+ export declare const CLASSIFIER_EVENT = "permission/classifier";
42
+ /** Consecutive per-route classifier failures before that route's breaker opens (module constant — no settings knob by design). */
43
+ export declare const CLASSIFIER_BREAKER_THRESHOLD = 3;
44
+ /** The `permission/classifier` payload. The raw classifier input NEVER appears — only its digest. */
45
+ export interface ClassifierAuditEventData {
46
+ /** The tool the verdict is about. */
47
+ tool: string;
48
+ /** sha256 of the rendered classifier input (absent on the arming `unarmed` record). */
49
+ digest?: string;
50
+ verdict: 'allow' | 'ask';
51
+ failure?: 'timeout' | 'error' | 'malformed' | 'unarmed' | 'breaker' | 'cancelled';
52
+ route?: string;
53
+ provider?: string;
54
+ model?: string;
55
+ latencyMs: number;
56
+ cacheHit: boolean;
57
+ }
58
+ /**
59
+ * Append one `permission/classifier` audit record through the widened session
60
+ * append face (same cross-pin strategy as `./mode.ts` and
61
+ * `./session-allowlist.ts`).
62
+ */
63
+ export declare function appendSessionClassifier(session: Session, data: ClassifierAuditEventData): void;
64
+ /**
65
+ * Fold a session log into the classifier verdict records it carries, in log
66
+ * order. Foreign event types are skipped; resume/replay reconstructs why a
67
+ * call did or did not prompt.
68
+ */
69
+ export declare function foldClassifiers(events: readonly SessionEvent[]): ClassifierAuditEventData[];
70
+ /**
71
+ * Structural dependency face the service supplies. `stream` is the llm
72
+ * adapter (undefined when the llm service is not mounted); `resolveRoute`
73
+ * resolves the configured alias route per call (undefined when unresolvable).
74
+ */
75
+ export type AutoStageDeps = {
76
+ /** The live `permissions` settings section (re-read on every call). */
77
+ settingsRead(): {
78
+ autoMode?: AutoModeSettings;
79
+ };
80
+ /**
81
+ * One-shot text completion over the auxiliary lane; `undefined` when no llm
82
+ * service is mounted (the stage then disarms).
83
+ */
84
+ stream: ((opts: {
85
+ provider: string;
86
+ model: string;
87
+ system: string;
88
+ prompt: string;
89
+ maxTokens: number;
90
+ signal?: AbortSignal;
91
+ }) => Promise<string>) | undefined;
92
+ /** Resolve the configured classifier route for this call's session. */
93
+ resolveRoute(exec: ToolExecution): {
94
+ provider: string;
95
+ model: string;
96
+ } | undefined;
97
+ /** Process logger for the one-time disarm warning. */
98
+ warn(message: string): void;
99
+ /** Durable audit sink (session append face, listener-owned). */
100
+ audit(session: Session, event: ClassifierAuditEventData): void;
101
+ /**
102
+ * Optional env-gated process-log sink for raw classifier output (R5) —
103
+ * never session events; wired only when DSH_PERMISSION_CLASSIFIER_DEBUG=1.
104
+ */
105
+ debug?: (message: string) => void;
106
+ };
107
+ /** The stage's contribution to one pre-execute decision: allow, an escalated ask, or nothing (legacy path). */
108
+ export type StageOutcome = 'allow' | {
109
+ kind: 'ask';
110
+ reason: string;
111
+ };
112
+ export type AutoStage = {
113
+ /** Drop the memoized classifier so the next armed call rebuilds it (settings onChange). */
114
+ rebuild(): void;
115
+ /**
116
+ * Maybe escalate one verbose decision. Returns a final decision only for
117
+ * the armed + `auto` + LOW + `ask`/`passthrough` slice (§4.1); every other
118
+ * shape returns undefined and the listener applies the legacy mapping
119
+ * unchanged — the LLM is then never invoked (I1–I3, I5).
120
+ */
121
+ maybeEscalate(decided: DecidedCall, exec: ToolExecution): Promise<StageOutcome | undefined>;
122
+ };
123
+ /**
124
+ * Trailing consecutive per-route failure streak over attributed classifier
125
+ * audit records (R3, pure fold — unit-testable). Only events carrying
126
+ * `provider`/`model` attribution matching `routeKey` count (unattributed
127
+ * legacy events predate route keying — skipped entirely); a parsed verdict or
128
+ * cache hit resets the streak; malformed/error/timeout increment it; other
129
+ * tags (`cancelled`, `breaker`, `unarmed`) are neutral. Capped at `threshold`.
130
+ */
131
+ export declare function trailingRouteFailureStreak(events: readonly ClassifierAuditEventData[], routeKey: string, threshold: number): number;
132
+ /**
133
+ * Build the stage. The classifier instance is memoized per autoMode slice:
134
+ * `rebuild()` (wired to the plugin's settings onChange/reload) drops it, and
135
+ * the next armed call rebuilds from the fresh slice — never per call.
136
+ */
137
+ export declare function createAutoStage(deps: AutoStageDeps): AutoStage;
138
+ //# sourceMappingURL=auto-stage.d.ts.map