dsh-workspace-acl-allow 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 张子凌
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # dsh-workspace-acl-allow
2
+
3
+ DeepSeek Harness 插件。为**你显式声明**的工作区目录预置一条 Windows ACL 授权,使 dsh 的文件沙箱能够正常启动受限执行进程,从而不再需要把会话升权到「完全访问」、也不再弹出升权确认框。
4
+
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![npm](https://img.shields.io/npm/v/dsh-workspace-acl-allow.svg?label=npm&labelColor=000000&color=ff4b01)](https://www.npmjs.com/package/dsh-memory-palace) [![DeepSeek Harness:0.1.7-rc.2](https://img.shields.io/badge/DeepSeek%20Harness-0.1.7--rc.2-success.svg?labelColor=4D6BFE)](https://github.com/deepseek-ai/deepseek-harness) [![Desktop: supported](https://img.shields.io/badge/Desktop-supported-success.svg?labelColor=4D6BFE)](#安装)
6
+
7
+ ## 它解决什么问题
8
+
9
+ **沟槽的deepseek harness**从 0.1.7 起,给 grantWrite 加了 Low 完整性标签,把权限门槛抬高了。
10
+
11
+ | | ≤0.1.6 | ≥0.1.7-alpha.1 |
12
+ | --- | --- | --- |
13
+ | grantWrite 写入内容 | 只写 DACL 能力 ACE | ACE + deny + Low 强制标签 |
14
+ | 需要的目录权限 | WRITE_DAC(owner 隐式自带) | 还要 WRITE_OWNER——owner 隐式权限不含它,必须 DACL 显式授予 |
15
+ | 对继承 Modify 的目录 | ✅ 静默成功 | ❌ 必败 Win32 5 |
16
+
17
+ 只被授予「修改」的普通目录不满足这一点,写入会以「拒绝访问」失败,执行进程启动即失败;模型随后按工具说明申请升权,于是每次都要人工确认一次。
18
+
19
+ 本插件在宿主进程内,为你声明过的目录补一条**完全控制**的授权项(其中已包含「修改属主」),使宿主自身那次写入得以通过。
20
+
21
+ 插件**不实现**宿主的授权逻辑:不处理能力标识、不写完整性标签、不改目录属主、不撤销任何权限。
22
+
23
+ ### 如果你也遇到了已下错误则本插件可能/也许/大概能解决你的问题
24
+ | tools error | step error] |
25
+ | --- | --- |
26
+ | ![tools error](assets/tools-error-1.png) | ![step error](assets/tools-error-2.png) |
27
+
28
+ ## 安装
29
+
30
+ ```
31
+ dsh plugin --profile web add dsh-workspace-acl-allow
32
+ ```
33
+
34
+ 本仓库为组合包,安装后其配置层会随 profile 一并加载。
35
+
36
+ ## 使用
37
+
38
+ 授权只在你**明确表达意图**时发生,有且只有两个入口:
39
+
40
+ 1. **在 dsh 中添加工作区** —— 工作区记录写入后立即完成授权。插件加载之前就已注册的工作区,会在插件启动时一并补齐。
41
+ 2. **在插件页手动新增路径** —— 打开「插件」页 → 本插件 → 详情页底部的「工作区授权白名单」,输入目录路径并新增。适合尚未注册为工作区的目录。
42
+
43
+ 列表中的每条记录都会带回状态:
44
+
45
+ | 状态 | 含义 |
46
+ |---|---|
47
+ | 已授权 | 授权项已就位 |
48
+ | 授权失败(退出码 …) | 授权未生效;常见原因是目录属主不是当前用户,或当前用户对该目录没有修改 DACL 的权限 |
49
+ | 受保护目录,已拒绝 | 命中内置保护清单,不会被授权(见下) |
50
+ | 目录不存在 | 路径无效 |
51
+
52
+ 每个条目都可以「重新授权」(忽略记录强制重跑一次)或「移除」(只删除本插件的记录,**不会**改动目录上已有的任何权限)。
53
+
54
+ ## 显式原则
55
+
56
+ 插件**不会**接管任何会话的工作目录。会话目录可以直接指向任意位置,插件不会因为在某个目录下开会话就静默放宽它的权限——这类目录需要你自己在插件页声明。这是刻意的取舍:放宽一个目录的权限属于安全敏感动作,必须由人发起。
57
+
58
+ ## 内置保护清单
59
+
60
+ 以下位置**始终拒绝**,无法通过配置或界面解除:
61
+
62
+ - 盘符根目录
63
+ - 系统目录(Windows 目录、程序文件目录、程序数据目录)
64
+ - 用户配置文件根目录(其下的子目录不受限)
65
+ - 回收站与系统卷信息目录
66
+
67
+ 另外不接受相对路径与网络路径。
68
+
69
+ ## 平台
70
+
71
+ **仅支持 Windows。** 非 Windows 平台上插件不生效(只记录一条日志)。本项目仅在 Windows 上开发与验证,没有其他平台的实测环境。
72
+
73
+ ## 副作用
74
+
75
+ - 被处理的目录上,当前用户会从「继承的修改」变为**显式的完全控制**(含修改权限与属主)。以该用户身份运行的任何进程对该目录的权限随之放宽。这与 dsh 工作区模型的既有代价同源——宿主授权成功后同样会留下常驻的授权项与完整性标签。
76
+ - 授权只作用于**工作区根目录一条授权项**,不递归修改子项权限(除非显式开启对应配置)。
77
+ - 移除列表条目不会撤销已生效的授权;撤销由 dsh 自身的授权体系负责。
78
+
79
+ ## 配置
80
+
81
+ 配置写在 profile 的配置层中,安装后即可生效。
82
+
83
+ | 字段 | 默认 | 说明 |
84
+ |---|---|---|
85
+ | `watchWorkspaces` | `true` | 是否订阅工作区新增事件(关闭后只剩手动入口) |
86
+ | `deepGrant` | `false` | 是否递归处理子项。仅在子目录继承被破坏时开启 |
87
+ | `retryCooldownMs` | `3600000` | 授权失败项的冷却时间(毫秒),冷却内不重复尝试 |
88
+ | `excludePaths` | `[]` | 追加的排除模式(支持 `*` 与 `**`),只能追加内置保护项之外的内容 |
89
+
90
+ ### 状态机
91
+ > $DSH_HOME/dsh-workspace-acl-allow/state.json
92
+
93
+ ## 卸载
94
+
95
+ ```
96
+ dsh plugin --profile web remove dsh-workspace-acl-allow
97
+ ```
98
+
99
+ 卸载会停止后续授权,但**不会**撤销已经写入目录的授权项。如需清理,请针对具体目录手工移除相应的授权条目。
100
+
101
+ ## 版本历史
102
+
103
+ - **0.1.0**
104
+ - 🔥dsh 新增加工作区自动授权
105
+ - 🔥UI 插件-dsh-workspace-acl-allow-工作区授权白名单
106
+ - 🔥UI 显示已授权工作区路径
107
+ - 🔥UI 支持手动新增 PATH 授权
108
+
109
+ ## License
110
+
111
+ [MIT](./LICENSE)
@@ -0,0 +1,6 @@
1
+ # dsh-workspace-acl-allow bundle patch:一个 insert 注册本插件。
2
+ # insert.name 必须是【包名】(dsh plugin add 后由 profile 的 node_modules 解析到 lib/index.js);
3
+ # insert.id 必须与入口导出的 cordis `name`('workspace-acl-allow')一致。
4
+ - insert:
5
+ - id: workspace-acl-allow
6
+ name: 'dsh-workspace-acl-allow'
package/lib/client.js ADDED
@@ -0,0 +1,300 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-workspace-acl-allow",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let React = require("react");
8
+
9
+
10
+ // ---- 白名单管理块:注册进 plugins.detail.section,仅在本插件自己的详情页渲染 ----
11
+ // 位置选择说明:plugins.detail.section 是「追加」型座位,渲染在详情页配置区之后,
12
+ // 因此不与宿主自动生成的配置表单争位;bundle.config / row.config 是「接管」型座位,
13
+ // 注册它们会让宿主不再自动生成该配置页,故此处不使用。
14
+ const PACKAGE_NAME = "dsh-workspace-acl-allow";
15
+ const ROUTE_BASE = "/workspace-acl-allow";
16
+
17
+ const CSS = [
18
+ ".dshacla-root{margin-top:12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:var(--dsw-alias-bg-layer-3);padding:16px;font:inherit;color:var(--dsw-alias-label-primary)}",
19
+ ".dshacla-head{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:4px}",
20
+ ".dshacla-title{font-size:15px;font-weight:600;line-height:1.4;margin:0}",
21
+ ".dshacla-desc{font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-tertiary);margin:0 0 12px}",
22
+ ".dshacla-meta{margin-left:auto;font-size:12px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}",
23
+ ".dshacla-knobs{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 12px}",
24
+ ".dshacla-knob{border:.5px solid var(--dsw-alias-border-l2);border-radius:999px;padding:2px 10px;font-size:11.5px;line-height:18px;color:var(--dsw-alias-label-secondary)}",
25
+ ".dshacla-add{display:flex;gap:8px;margin-bottom:12px}",
26
+ ".dshacla-input{flex:1;min-width:0;box-sizing:border-box;border:.5px solid var(--dsw-alias-border-l1);border-radius:8px;padding:7px 10px;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base)}",
27
+ ".dshacla-input:focus{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}",
28
+ ".dshacla-btn{appearance:none;border:1px solid transparent;border-radius:8px;padding:5px 14px;font:inherit;font-size:13px;line-height:1.5;cursor:pointer;white-space:nowrap}",
29
+ ".dshacla-btn:disabled{opacity:.4;cursor:default}",
30
+ ".dshacla-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}",
31
+ ".dshacla-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}",
32
+ ".dshacla-btn-ghost{border-color:var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-secondary)}",
33
+ ".dshacla-btn-ghost:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}",
34
+ ".dshacla-btn-danger{border-color:var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-secondary)}",
35
+ ".dshacla-btn-danger:hover:not(:disabled){color:#c86464;border-color:#c86464}",
36
+ ".dshacla-btn-tiny{padding:3px 10px;font-size:12px}",
37
+ ".dshacla-list{list-style:none;margin:0;padding:0;border-top:.5px solid var(--dsw-alias-border-l2)}",
38
+ ".dshacla-item{display:flex;align-items:flex-start;gap:10px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}",
39
+ ".dshacla-item:last-child{border-bottom:0}",
40
+ ".dshacla-item-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}",
41
+ ".dshacla-path{font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;word-break:break-all}",
42
+ ".dshacla-sub{font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-tertiary);word-break:break-all}",
43
+ ".dshacla-ops{display:flex;gap:6px;flex:0 0 auto}",
44
+ ".dshacla-badge{flex:0 0 auto;border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;font-weight:500;white-space:nowrap;border:.5px solid transparent}",
45
+ ".dshacla-badge-ok{background:color-mix(in srgb,#5d8a6c 18%,transparent);color:#5d8a6c;border-color:color-mix(in srgb,#5d8a6c 35%,transparent)}",
46
+ ".dshacla-badge-bad{background:color-mix(in srgb,#c86464 18%,transparent);color:#c86464;border-color:color-mix(in srgb,#c86464 35%,transparent)}",
47
+ ".dshacla-badge-mute{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-tertiary);border-color:var(--dsw-alias-border-l2)}",
48
+ ".dshacla-empty{padding:18px 0;font-size:12.5px;color:var(--dsw-alias-label-tertiary)}",
49
+ ".dshacla-note{flex:1;min-width:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary)}",
50
+ ".dshacla-note-bad{color:#c86464}",
51
+ ].join("\n");
52
+
53
+ let stylesInjected = false;
54
+ function injectStyles() {
55
+ if (stylesInjected) return;
56
+ stylesInjected = true;
57
+ const node = document.createElement("style");
58
+ node.setAttribute("data-dsh-workspace-acl-allow", "");
59
+ node.textContent = CSS;
60
+ document.head.appendChild(node);
61
+ }
62
+
63
+ function subjectIsOurs(subject) {
64
+ if (subject === null || typeof subject !== "object") return false;
65
+ if (subject.kind !== "bundle" && subject.kind !== "row") return false;
66
+ const pkg = subject.pkg;
67
+ return pkg !== null && typeof pkg === "object" && pkg.name === PACKAGE_NAME;
68
+ }
69
+
70
+ // 本地时区时间:按日期落文案须用本地字段,UTC 会跨日错位。
71
+ function formatTime(value) {
72
+ if (typeof value !== "number" || !Number.isFinite(value)) return "";
73
+ const d = new Date(value);
74
+ const pad = (n) => (n < 10 ? "0" + n : String(n));
75
+ return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " "
76
+ + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
77
+ }
78
+
79
+ function statusLabel(status) {
80
+ if (typeof status !== "string" || status.length === 0) return "未知";
81
+ if (status === "granted") return "已授权";
82
+ if (status === "skipped:missing") return "目录不存在";
83
+ if (status === "skipped:blacklist") return "受保护目录,已拒绝";
84
+ if (status === "skipped:not-windows") return "非 Windows";
85
+ if (status === "skipped:empty") return "空路径";
86
+ if (status === "skipped:malformed") return "记录异常";
87
+ if (status === "skipped:workspace-unregistered") return "工作区已移除(不撤权)";
88
+ if (status === "failed:no-grantee") return "无法解析当前用户";
89
+ if (status.startsWith("failed:")) return "授权失败(退出码 " + status.slice(7) + ")";
90
+ if (status.startsWith("skipped:")) return "已跳过(" + status.slice(8) + ")";
91
+ return status;
92
+ }
93
+
94
+ function statusKind(status) {
95
+ if (status === "granted") return "ok";
96
+ if (typeof status === "string" && status.startsWith("failed:")) return "bad";
97
+ return "mute";
98
+ }
99
+
100
+ async function apiJson(path, init) {
101
+ const response = await fetch(path, Object.assign({ cache: "no-store", credentials: "same-origin" }, init || {}));
102
+ const text = await response.text();
103
+ let data = null;
104
+ try { data = text.length > 0 ? JSON.parse(text) : null; } catch { /* 非 JSON 响应按空处理 */ }
105
+ if (!response.ok) {
106
+ const base = (data && (data.error || data.message)) || (response.status + " " + response.statusText);
107
+ // 404/405 表示请求落到了宿主的静态兜底,说明插件服务端接口未就位。
108
+ const hint = response.status === 404 || response.status === 405
109
+ ? "(宿主的插件接口未就位,请重启 dsh 后重试)"
110
+ : "";
111
+ throw new Error(base + hint);
112
+ }
113
+ return data;
114
+ }
115
+
116
+ // 变更结果如实回显:未授权/失败/跳过都不入库,只回显,否则界面会以为什么都没发生。
117
+ function describeOutcome(action, data) {
118
+ const target = data && typeof data.path === "string" ? data.path : "";
119
+ if (action === "remove") {
120
+ return data && data.removed
121
+ ? { bad: false, text: "已移除条目:" + target + "(未改动目录上已有的权限)" }
122
+ : { bad: true, text: "该条目不存在:" + target };
123
+ }
124
+ const entry = data ? data.entry : null;
125
+ const status = entry ? entry.status : undefined;
126
+ if (status === "granted") return { bad: false, text: "已授权:" + target };
127
+ if (status === "skipped:missing") return { bad: true, text: "未授权(目录不存在):" + target };
128
+ if (status === "skipped:blacklist") return { bad: true, text: "未授权(受保护目录,已拒绝):" + target };
129
+ if (status === "skipped:not-windows") return { bad: true, text: "未授权(非 Windows 平台)" };
130
+ if (status === "skipped:empty") return { bad: true, text: "请输入目录路径" };
131
+ if (typeof status === "string" && status.startsWith("failed:")) {
132
+ return {
133
+ bad: true,
134
+ text: "授权失败(退出码 " + status.slice(7) + "):" + target
135
+ + (entry.lastError ? " — " + entry.lastError : ""),
136
+ };
137
+ }
138
+ return { bad: true, text: "未授权:" + target + " → " + statusLabel(status) };
139
+ }
140
+
141
+ function Badge(props) {
142
+ return React.createElement("span", { className: "dshacla-badge dshacla-badge-" + statusKind(props.status) },
143
+ statusLabel(props.status));
144
+ }
145
+
146
+ function WhitelistSection(props) {
147
+ // 详情页对每个主题都会问一次;非本插件主题不渲染任何内容。
148
+ const ours = subjectIsOurs(props.subject);
149
+ const [view, setView] = React.useState(null);
150
+ const [busy, setBusy] = React.useState(false);
151
+ const [draft, setDraft] = React.useState("");
152
+ const [note, setNote] = React.useState("");
153
+ const [noteBad, setNoteBad] = React.useState(false);
154
+
155
+ const load = React.useCallback(() => {
156
+ apiJson(ROUTE_BASE + "/state")
157
+ .then((data) => { if (data && data.ok) setView(data); })
158
+ .catch((error) => { setNoteBad(true); setNote("读取失败:" + String(error.message || error)); });
159
+ }, []);
160
+
161
+ React.useEffect(() => {
162
+ if (!ours) return undefined;
163
+ load();
164
+ return undefined;
165
+ }, [ours, load]);
166
+
167
+ if (!ours) return null;
168
+
169
+ const run = (action, endpoint, body) => {
170
+ if (busy) return;
171
+ setBusy(true);
172
+ setNote("");
173
+ setNoteBad(false);
174
+ apiJson(endpoint, {
175
+ method: "POST",
176
+ headers: { "content-type": "application/json" },
177
+ body: JSON.stringify(body || {}),
178
+ })
179
+ .then((data) => {
180
+ const outcome = describeOutcome(action, data);
181
+ setNoteBad(outcome.bad);
182
+ setNote(outcome.text);
183
+ load();
184
+ })
185
+ .catch((error) => { setNoteBad(true); setNote("操作失败:" + String(error.message || error)); })
186
+ .finally(() => setBusy(false));
187
+ };
188
+
189
+ const list = view && Array.isArray(view.whitelist) ? view.whitelist : [];
190
+ const config = (view && view.config) || {};
191
+
192
+ const knobs = React.createElement("div", { className: "dshacla-knobs" },
193
+ React.createElement("span", { className: "dshacla-knob" },
194
+ "工作区事件:" + (config.watchWorkspaces === false ? "关" : "开")),
195
+ React.createElement("span", { className: "dshacla-knob" },
196
+ "递归子项(/T):" + (config.deepGrant ? "开" : "关")),
197
+ React.createElement("span", { className: "dshacla-knob" },
198
+ "失败冷却:" + Math.round((Number(config.retryCooldownMs) || 0) / 60000) + " 分钟"));
199
+
200
+ const rows = list.length === 0
201
+ ? React.createElement("li", { className: "dshacla-empty" },
202
+ "还没有条目。在 dsh 中添加工作区,或在此手动新增路径。"
203
+ + "只有成功授权的目录会进入清单;被拒绝或失败的只看下方结果行。")
204
+ : list.map((item) => React.createElement("li", { className: "dshacla-item", key: item.path },
205
+ React.createElement("div", { className: "dshacla-item-main" },
206
+ React.createElement("span", { className: "dshacla-path" }, item.path),
207
+ React.createElement("span", { className: "dshacla-sub" },
208
+ (item.at ? formatTime(item.at) : "—")
209
+ + (item.attempts ? " · 尝试 " + item.attempts + " 次" : "")
210
+ + (item.lastError ? " · " + item.lastError : ""))),
211
+ React.createElement("span", { className: "dshacla-ops" },
212
+ React.createElement("span", { className: "dshacla-badge dshacla-badge-" + statusKind(item.status) },
213
+ statusLabel(item.status)),
214
+ React.createElement("button", {
215
+ type: "button",
216
+ className: "dshacla-btn dshacla-btn-ghost dshacla-btn-tiny",
217
+ disabled: busy,
218
+ onClick: () => run("regrant", ROUTE_BASE + "/regrant", { path: item.path }),
219
+ }, "重新授权"),
220
+ React.createElement("button", {
221
+ type: "button",
222
+ className: "dshacla-btn dshacla-btn-danger dshacla-btn-tiny",
223
+ disabled: busy,
224
+ onClick: () => run("remove", ROUTE_BASE + "/remove", { path: item.path }),
225
+ }, "移除"))));
226
+
227
+ const supported = view === null || view.supported !== false;
228
+
229
+ return React.createElement("section", { className: "dshacla-root", "aria-label": "工作区授权白名单" },
230
+ React.createElement("div", { className: "dshacla-head" },
231
+ React.createElement("h3", { className: "dshacla-title" }, "工作区授权白名单"),
232
+ React.createElement("span", { className: "dshacla-meta" },
233
+ view === null ? "读取中…" : "共 " + list.length + " 条"),
234
+ React.createElement("button", {
235
+ type: "button",
236
+ className: "dshacla-btn dshacla-btn-ghost dshacla-btn-tiny",
237
+ disabled: busy,
238
+ onClick: () => { setNote(""); load(); },
239
+ }, "刷新")),
240
+ React.createElement("p", { className: "dshacla-desc" },
241
+ "只处理此处显式声明的目录:为当前用户补一条完全控制的 ACL 条目,使 dsh 沙箱的授权调用得以通过。"
242
+ + "本插件不会撤销任何权限,移除条目也不会改动目录上已有的 ACL。"),
243
+ supported ? null : React.createElement("p", { className: "dshacla-desc" },
244
+ "当前平台非 Windows,本插件不生效。"),
245
+ supported ? knobs : null,
246
+ supported ? React.createElement("div", { className: "dshacla-add" },
247
+ React.createElement("input", {
248
+ className: "dshacla-input",
249
+ type: "text",
250
+ spellCheck: false,
251
+ placeholder: "输入目录的绝对路径,回车即授权",
252
+ value: draft,
253
+ disabled: busy,
254
+ onChange: (event) => setDraft(event.target.value),
255
+ onKeyDown: (event) => {
256
+ if (event.key !== "Enter") return;
257
+ const value = draft.trim();
258
+ if (value.length === 0) return;
259
+ run("add", ROUTE_BASE + "/add", { path: value });
260
+ setDraft("");
261
+ },
262
+ }),
263
+ React.createElement("button", {
264
+ type: "button",
265
+ className: "dshacla-btn dshacla-btn-primary",
266
+ disabled: busy || draft.trim().length === 0,
267
+ onClick: () => {
268
+ const value = draft.trim();
269
+ if (value.length === 0) return;
270
+ run("add", ROUTE_BASE + "/add", { path: value });
271
+ setDraft("");
272
+ },
273
+ }, busy ? "处理中…" : "新增授权")) : null,
274
+ supported ? React.createElement("ul", { className: "dshacla-list" }, rows) : null,
275
+ note.length > 0
276
+ ? React.createElement("p", { className: "dshacla-note" + (noteBad ? " dshacla-note-bad" : "") }, note)
277
+ : null,
278
+ view && typeof view.stateFile === "string"
279
+ ? React.createElement("p", { className: "dshacla-sub" }, "状态文件:" + view.stateFile)
280
+ : null);
281
+ }
282
+
283
+
284
+ function apply(ctx) {
285
+ ctx.effect(() => injectStyles());
286
+ // 详情页追加区:宿主按主题分发,本块对非本插件主题返回 null。
287
+ // plugins.detail.section 由 client-ui-plugin-manager 的 main 入口声明,
288
+ // slots.inject 会等待该座位出现,故无需在 inject 里声明硬依赖。
289
+ ctx.slots.inject("plugins.detail.section", () => ctx.slots.register({
290
+ name: "plugins.detail.section",
291
+ id: "workspace-acl-allow",
292
+ order: 100,
293
+ }, WhitelistSection));
294
+ }
295
+
296
+ exports.apply = apply;
297
+ exports.inject = ["slots"];
298
+ return module.exports;
299
+ }
300
+ });
package/lib/index.js ADDED
@@ -0,0 +1,627 @@
1
+ // dsh-workspace-acl-allow — 为工作区目录预置 Windows ACL 完全控制 ACE。
2
+ //
3
+ // 背景:dsh 启动受限 worker 前,须对工作区根目录调用一次 SetNamedSecurityInfoW,
4
+ // 同时写入 DACL 与 Low 强制标签。标签位于 SACL,而 owner 的隐式权限只覆盖
5
+ // READ_CONTROL 与 WRITE_DAC,因此该调用要求调用者对目录拥有 WRITE_OWNER;
6
+ // 只授予 Modify 的目录会以 Win32 5(拒绝访问)失败,worker 启动即死。
7
+ //
8
+ // 本插件在宿主进程内向用户**显式声明**的目录补一条 (OI)(CI)F 的 allow ACE
9
+ // (F 含 WRITE_OWNER),使宿主自身那次组合调用得以通过。插件不实现宿主的授权
10
+ // 逻辑:不碰能力 SID、不写强制标签、不改 owner、不撤销任何 ACE。
11
+ //
12
+ // 触发仅两条,均为用户显式意图:① 工作区域新增记录(用户在 dsh 添加工作区);
13
+ // ② 插件设置页手动新增路径。不监听会话创建,也不接管任何会话 cwd。
14
+ import { execFile } from "node:child_process";
15
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join, resolve } from "node:path";
18
+ import { promisify } from "node:util";
19
+ import Schema from "@deepseek-ai/schemastery";
20
+
21
+ // cordis 服务名,必须与 cordis.patch.yml 的 insert.id 一致。
22
+ export const name = "workspace-acl-allow";
23
+
24
+ // 静态声明依赖:webServer 承载设置页的同源路由,webRuntime 提供可信来源清单。
25
+ // 依赖就绪前 fiber 处于等待态而非失败态;这与宿主既有插件(内存宫)的写法一致。
26
+ export const inject = ["webServer", "webRuntime"];
27
+
28
+ const execFileAsync = promisify(execFile);
29
+
30
+ const DOMAIN_NAME = "workspace";
31
+ const TABLE_NAME = "workspaces";
32
+ const PACKAGE_NAME = "dsh-workspace-acl-allow";
33
+ const ROUTE_PREFIX = "/workspace-acl-allow";
34
+ const STATE_DIR_NAME = PACKAGE_NAME;
35
+ const STATE_FILE_NAME = "state.json";
36
+
37
+ /** 授权模板:完全控制,含 WRITE_OWNER,与宿主成功路径留下的形态一致。 */
38
+ const GRANT_TEMPLATE = "(OI)(CI)F";
39
+
40
+ const IS_WINDOWS = process.platform === "win32";
41
+
42
+ // 字段一律不加 volatile:不同 profile 解析到的是不同的 schemastery 副本——
43
+ // web profile 落到 profile 自带的旧副本(无该 API),desktop profile 落到全局安装
44
+ // 内嵌的新副本(有该 API)。加了会让 web 侧在加载时抛错,不加则两边都能加载。
45
+ // 代价是配置项不经设置页写回,作为安装期开关由 profile 配置层维护。
46
+ // 读值一律经 unwrap():无论宿主是否把字段包装成引用都能正确取值。
47
+ export const Config = Schema.object({
48
+ watchWorkspaces: Schema.boolean()
49
+ .default(true)
50
+ .description("订阅工作区新增事件:在 dsh 中添加工作区时自动完成授权。"),
51
+ deepGrant: Schema.boolean()
52
+ .default(false)
53
+ .description("icacls 是否附加 /T 递归处理子项。仅在子目录继承被破坏时开启。"),
54
+ retryCooldownMs: Schema.number()
55
+ .default(3600000)
56
+ .description("授权失败项的冷却时间(毫秒);冷却内不重试,避免重复触发。"),
57
+ excludePaths: Schema.array(Schema.string())
58
+ .default([])
59
+ .description("追加的排除模式(支持 * 与 ** 通配)。只能追加,不能解除内置系统目录保护。"),
60
+ });
61
+
62
+ // ---- 同源与可信来源围栏 ----
63
+ // 这些路由开在本机 HTTP 面上:必须拒绝跨站与不可信 Host 发起的请求,
64
+ // 否则任意本机页面或进程都能借道触发一次目录授权。
65
+
66
+ function headerValue(headers, name) {
67
+ const value = headers?.[name];
68
+ if (Array.isArray(value)) return typeof value[0] === "string" ? value[0] : undefined;
69
+ return typeof value === "string" ? value : undefined;
70
+ }
71
+
72
+ function isLoopbackHostname(hostname) {
73
+ if (hostname === "localhost" || hostname === "[::1]") return true;
74
+ const parts = hostname.split(".");
75
+ return parts.length === 4 && parts[0] === "127"
76
+ && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
77
+ }
78
+
79
+ /** 可信条目未带端口时只比主机名,带端口时比完整 authority。 */
80
+ function authorityMatches(hostUrl, entry) {
81
+ const raw = String(entry).trim();
82
+ if (raw.length === 0) return false;
83
+ let entryUrl;
84
+ try {
85
+ entryUrl = new URL(`http://${raw}`);
86
+ } catch {
87
+ return false;
88
+ }
89
+ return /:\d+$/.test(raw) ? entryUrl.host === hostUrl.host : entryUrl.hostname === hostUrl.hostname;
90
+ }
91
+
92
+ function isTrustedRequest(request, trustedHosts) {
93
+ const host = headerValue(request?.headers, "host");
94
+ if (typeof host !== "string" || host.length === 0) return false;
95
+ let hostUrl;
96
+ try {
97
+ hostUrl = new URL(`http://${host}`);
98
+ } catch {
99
+ return false;
100
+ }
101
+ const trusted = Array.isArray(trustedHosts) ? trustedHosts : [];
102
+ if (!isLoopbackHostname(hostUrl.hostname) && !trusted.some((entry) => authorityMatches(hostUrl, entry))) {
103
+ return false;
104
+ }
105
+ if (headerValue(request.headers, "sec-fetch-site") === "cross-site") return false;
106
+ const origin = headerValue(request.headers, "origin");
107
+ if (origin === undefined) return true;
108
+ try {
109
+ return new URL(origin).host === hostUrl.host;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /** 解包 volatile 配置引用;直接给值时原样返回,两种形态都能读。 */
116
+ function unwrap(value) {
117
+ if (value === null || typeof value !== "object") return value;
118
+ if (typeof value.get === "function" && !Array.isArray(value)) return unwrap(value.get());
119
+ return value;
120
+ }
121
+
122
+ // ---- 路径规范化 ----
123
+
124
+ /**
125
+ * 规范化到真实路径:符号链接、短名、大小写别名收敛为一种拼写。
126
+ * 规范化只服务于本插件自己的去重键与黑名单比对基准——ACL 操作由 Windows
127
+ * 自行解析路径,拼写差异不影响授权本身能否成功。
128
+ * @param {string} target - 目标路径。
129
+ * @returns {string|null} 真实路径;路径不存在时返回 null。
130
+ */
131
+ function canonicalize(target) {
132
+ try {
133
+ return realpathSync.native(target);
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /** 比较用的归一形式:统一小写,去掉末尾多余分隔符(保留盘符根)。 */
140
+ function normalizeForCompare(target) {
141
+ const lowered = target.replace(/\//g, "\\").toLowerCase();
142
+ if (/^[a-z]:\\$/.test(lowered)) return lowered;
143
+ return lowered.replace(/\\+$/, "");
144
+ }
145
+
146
+ // ---- 内置黑名单(配置与 UI 均不可解除) ----
147
+
148
+ /**
149
+ * 内置拒绝清单分两类,语义不同,不能混:
150
+ * - `tree`:自身及其子目录全拒——系统安装目录、程序目录、程序数据目录。
151
+ * - `self`:仅拒自身——用户配置文件根。其下目录(如 .dsh / .codebuddy 等)是正常的
152
+ * 工作区位置,整棵子树一并拒掉会误伤。
153
+ * 盘符根与回收站/系统卷信息不在此列,由 isBuiltinDenied 直接判定。
154
+ * @returns 两类规范化后的拒绝集合。
155
+ */
156
+ function builtinDenyList() {
157
+ const tree = [];
158
+ const self = [];
159
+ const push = (list, value) => {
160
+ if (typeof value === "string" && value.trim().length > 0) list.push(value.trim());
161
+ };
162
+ push(tree, process.env.SystemRoot);
163
+ push(tree, process.env.windir);
164
+ push(tree, process.env.ProgramFiles);
165
+ push(tree, process.env["ProgramFiles(x86)"]);
166
+ push(tree, process.env.ProgramData);
167
+ push(self, process.env.USERPROFILE);
168
+
169
+ const normalize = (value) => {
170
+ const resolved = resolve(value);
171
+ const real = canonicalize(resolved);
172
+ return normalizeForCompare(real ?? resolved);
173
+ };
174
+ return { tree: new Set(tree.map(normalize)), self: new Set(self.map(normalize)) };
175
+ }
176
+
177
+ /** 内置拒绝:盘符根、系统目录、用户配置文件根、回收站与系统卷信息。 */
178
+ function isBuiltinDenied(canonicalPath) {
179
+ const target = normalizeForCompare(canonicalPath);
180
+ if (/^[a-z]:\\?$/.test(target)) return true;
181
+ const segments = target.split("\\");
182
+ const leaf = segments[segments.length - 1] ?? "";
183
+ if (leaf === "$recycle.bin" || leaf === "system volume information") return true;
184
+ const { tree, self } = builtinDenyList();
185
+ if (self.has(target)) return true;
186
+ for (const denied of tree) {
187
+ if (target === denied || target.startsWith(denied + "\\")) return true;
188
+ }
189
+ return false;
190
+ }
191
+
192
+ /** 把简单通配模式编译为正则:** 跨分隔符,* 不跨分隔符。 */
193
+ function globToRegExp(pattern) {
194
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
195
+ const source = escaped
196
+ .replace(/\*\*/g, "\u0000")
197
+ .replace(/\*/g, "[^\\\\]*")
198
+ .replace(/\u0000/g, ".*")
199
+ .replace(/\?/g, "[^\\\\]");
200
+ return new RegExp("^" + source + "$", "i");
201
+ }
202
+
203
+ function isConfiguredDenied(canonicalPath, patterns) {
204
+ if (!Array.isArray(patterns) || patterns.length === 0) return false;
205
+ const target = normalizeForCompare(canonicalPath);
206
+ for (const raw of patterns) {
207
+ if (typeof raw !== "string" || raw.trim().length === 0) continue;
208
+ let compiled;
209
+ try {
210
+ compiled = globToRegExp(normalizeForCompare(resolve(raw)).replace(/\\+$/, ""));
211
+ } catch {
212
+ continue;
213
+ }
214
+ if (compiled.test(target)) return true;
215
+ }
216
+ return false;
217
+ }
218
+
219
+ // ---- 落盘状态(插件自有 JSON,位置由宿主 home-paths 解析,不硬编码) ----
220
+
221
+ function stateDir() {
222
+ const home = resolveDshHome();
223
+ return join(home, STATE_DIR_NAME);
224
+ }
225
+
226
+ /** 解析 harness home:显式 > $DSH_HOME > ~/.dsh(与宿主同一套优先级)。 */
227
+ function resolveDshHome() {
228
+ const fromEnv = process.env.DSH_HOME;
229
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return resolve(fromEnv.trim());
230
+ return join(homedir(), ".dsh");
231
+ }
232
+
233
+ function stateFilePath() {
234
+ return join(stateDir(), STATE_FILE_NAME);
235
+ }
236
+
237
+ function loadState(logger) {
238
+ const file = stateFilePath();
239
+ try {
240
+ if (!existsSync(file)) return {};
241
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
242
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
243
+ const records = parsed.records;
244
+ if (records === null || typeof records !== "object" || Array.isArray(records)) return {};
245
+ return records;
246
+ } catch (error) {
247
+ // 状态文件损坏不影响授权能力,只丢失跨重启短路;从空状态继续。
248
+ logger.warn(`state file is unreadable, starting empty: ${String(error)}`);
249
+ return {};
250
+ }
251
+ }
252
+
253
+ /** 原子写:先写临时文件再 rename,避免中断留下半截 JSON。 */
254
+ function saveState(records, logger) {
255
+ try {
256
+ mkdirSync(stateDir(), { recursive: true });
257
+ const file = stateFilePath();
258
+ const temp = file + ".tmp";
259
+ const payload = { unit: { name: PACKAGE_NAME, version: 1 }, records };
260
+ writeFileSync(temp, JSON.stringify(payload, null, 2), "utf8");
261
+ renameSync(temp, file);
262
+ return true;
263
+ } catch (error) {
264
+ logger.warn(`state file write failed: ${String(error)}`);
265
+ return false;
266
+ }
267
+ }
268
+
269
+ // ---- 当前用户 SID ----
270
+
271
+ let cachedSid;
272
+
273
+ /** 取当前进程用户的 SID;SID 文本与系统语言无关,icacls 用 *SID 形式可免除名称歧义。 */
274
+ async function currentUserSid() {
275
+ if (cachedSid !== undefined) return cachedSid;
276
+ cachedSid = null;
277
+ try {
278
+ const { stdout } = await execFileAsync("whoami", ["/user", "/fo", "csv", "/nh"], { windowsHide: true });
279
+ const matched = /S-\d-(?:\d+-)+\d+/.exec(String(stdout));
280
+ if (matched !== null) cachedSid = matched[0];
281
+ } catch {
282
+ cachedSid = null;
283
+ }
284
+ return cachedSid;
285
+ }
286
+
287
+ /** icacls 的受托者写法:优先 SID,取不到时退回 域\用户名。 */
288
+ async function granteeSpec() {
289
+ const sid = await currentUserSid();
290
+ if (typeof sid === "string" && sid.length > 0) return `*${sid}`;
291
+ const domain = process.env.USERDOMAIN;
292
+ const username = process.env.USERNAME;
293
+ if (typeof username === "string" && username.length > 0) {
294
+ return typeof domain === "string" && domain.length > 0 ? `${domain}\\${username}` : username;
295
+ }
296
+ return null;
297
+ }
298
+
299
+ // ---- 授权执行 ----
300
+
301
+ async function runGrant(canonicalPath, deepGrant) {
302
+ const grantee = await granteeSpec();
303
+ if (grantee === null) {
304
+ return { status: "failed:no-grantee", lastError: "cannot resolve current user identity" };
305
+ }
306
+ const args = [canonicalPath, "/grant", `${grantee}:${GRANT_TEMPLATE}`];
307
+ if (deepGrant) args.push("/T");
308
+ try {
309
+ await execFileAsync("icacls", args, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
310
+ return { status: "granted" };
311
+ } catch (error) {
312
+ // 只看退出码,不解析本地化输出;输出原文仅留作诊断。
313
+ const code = error?.code === undefined ? "exec" : String(error.code);
314
+ const detail = String(error?.stderr ?? error?.message ?? "").trim().slice(0, 400);
315
+ return { status: `failed:${code}`, lastError: detail };
316
+ }
317
+ }
318
+
319
+ // ---- 插件主体 ----
320
+
321
+ /**
322
+ * 打开一次插件实例。所有状态与副作用都由返回的句柄持有,fiber 卸载即失效。
323
+ * @param {object} ctx - cordis 上下文。
324
+ * @param {object} config - 已校验的 Config 实例(字段为 volatile 引用)。
325
+ */
326
+ export function apply(ctx, config) {
327
+ const logger = ctx.logger;
328
+
329
+ if (!IS_WINDOWS) {
330
+ logger.warn("non-Windows platform: inactive (workspace ACL bootstrapping is Windows-only)");
331
+ return;
332
+ }
333
+
334
+ let records = loadState(logger);
335
+ const hot = new Map();
336
+ const pending = new Map();
337
+
338
+ const readConfig = () => ({
339
+ watchWorkspaces: unwrap(config.watchWorkspaces) !== false,
340
+ deepGrant: unwrap(config.deepGrant) === true,
341
+ retryCooldownMs: Number(unwrap(config.retryCooldownMs)) || 3600000,
342
+ excludePaths: unwrap(config.excludePaths) ?? [],
343
+ });
344
+
345
+ const persist = () => { saveState(records, logger); };
346
+
347
+ const recordFor = (canonicalPath) => records[canonicalPath] ?? null;
348
+
349
+ function store(canonicalPath, entry) {
350
+ records[canonicalPath] = entry;
351
+ hot.set(canonicalPath, entry);
352
+ persist();
353
+ }
354
+
355
+ /**
356
+ * 幂等入口:三级短路(内存 → 落盘 granted → 执行 icacls)。
357
+ * @param {string} target - 待授权目录。
358
+ * @param {string} reason - 触发来源,仅用于日志。
359
+ * @param {boolean} force - 忽略短路强制重跑(设置页「重新授权」用)。
360
+ * @returns {Promise<object>} 该目录的最新状态记录。
361
+ */
362
+ async function ensureAcl(target, reason, force = false) {
363
+ if (!IS_WINDOWS) return { status: "skipped:not-windows", at: Date.now(), attempts: 0 };
364
+ if (typeof target !== "string" || target.trim().length === 0) {
365
+ return { status: "skipped:empty", at: Date.now(), attempts: 0 };
366
+ }
367
+
368
+ const resolved = resolve(target.trim());
369
+ const canonicalPath = canonicalize(resolved);
370
+ if (canonicalPath === null) {
371
+ logger.info(`[${reason}] skipped:missing ${resolved}`);
372
+ return { status: "skipped:missing", at: Date.now(), attempts: 0 };
373
+ }
374
+
375
+ const inFlight = pending.get(canonicalPath);
376
+ if (inFlight !== undefined && !force) return inFlight;
377
+
378
+ const run = (async () => {
379
+ const { deepGrant, retryCooldownMs, excludePaths } = readConfig();
380
+
381
+ if (!force) {
382
+ const cached = hot.get(canonicalPath);
383
+ if (cached !== undefined && cached.status === "granted") return cached;
384
+ const stored = recordFor(canonicalPath);
385
+ if (stored !== null && stored.status === "granted") {
386
+ hot.set(canonicalPath, stored);
387
+ return stored;
388
+ }
389
+ }
390
+
391
+ // skipped:* 是确定性的(由路径与配置即可推出),不落盘也不进热缓存:
392
+ // 落盘清单只承载「已声明并尝试授权过」的目录,即 granted 与 failed。
393
+ if (isBuiltinDenied(canonicalPath) || isConfiguredDenied(canonicalPath, excludePaths)) {
394
+ logger.warn(`[${reason}] skipped:blacklist ${canonicalPath}`);
395
+ return { status: "skipped:blacklist", at: Date.now(), attempts: 0 };
396
+ }
397
+
398
+ const previous = recordFor(canonicalPath);
399
+ if (!force && previous !== null && typeof previous.status === "string"
400
+ && previous.status.startsWith("failed:")
401
+ && typeof previous.at === "number" && Date.now() - previous.at < retryCooldownMs) {
402
+ return previous;
403
+ }
404
+
405
+ const result = await runGrant(canonicalPath, deepGrant);
406
+ const entry = {
407
+ status: result.status,
408
+ at: Date.now(),
409
+ attempts: (previous?.attempts ?? 0) + 1,
410
+ ...result.lastError === undefined ? {} : { lastError: result.lastError },
411
+ };
412
+ if (result.status === "granted") {
413
+ logger.info(`[${reason}] granted ${canonicalPath}`);
414
+ } else {
415
+ logger.warn(`[${reason}] ${result.status} ${canonicalPath}${entry.lastError ? " :: " + entry.lastError : ""}`);
416
+ }
417
+ store(canonicalPath, entry);
418
+ return entry;
419
+ })();
420
+
421
+ pending.set(canonicalPath, run);
422
+ try {
423
+ return await run;
424
+ } finally {
425
+ pending.delete(canonicalPath);
426
+ }
427
+ }
428
+
429
+ // ---- 通道一:工作区域新增记录 ----
430
+
431
+ function onDomainChanged(change) {
432
+ // 三个字段全中才处理:本插件自己的落盘不经过 storage 域,但该过滤仍是防止
433
+ // 其他域写入(以及未来变化)引发误触发与递归的唯一依据。
434
+ if (change === null || typeof change !== "object") return;
435
+ if (change.domain !== DOMAIN_NAME || change.table !== TABLE_NAME) return;
436
+ if (change.operation !== "put") {
437
+ if (change.operation === "deleted") {
438
+ logger.info(`skipped:workspace-unregistered ${typeof change.key === "string" ? change.key : ""}`);
439
+ }
440
+ return;
441
+ }
442
+ const value = change.value;
443
+ if (value === null || typeof value !== "object" || typeof value.path !== "string") {
444
+ logger.warn("skipped:malformed workspace record on domain/changed");
445
+ return;
446
+ }
447
+ // 事件回调只做判重与入队,阻塞 I/O 交给异步链。
448
+ void ensureAcl(value.path, "workspace-event").catch((error) => {
449
+ logger.warn(`workspace-event handler failed: ${String(error)}`);
450
+ });
451
+ }
452
+
453
+ if (readConfig().watchWorkspaces) {
454
+ ctx.on("domain/changed", onDomainChanged);
455
+ }
456
+
457
+ // ---- 加载时补漏:覆盖插件加载之前已注册的工作区(同属通道一语义) ----
458
+
459
+ async function sweepRegistered() {
460
+ try {
461
+ const registry = ctx.get("workspaceRegistry");
462
+ if (registry === undefined || registry === null || typeof registry.list !== "function") return;
463
+ const list = registry.list();
464
+ if (!Array.isArray(list)) return;
465
+ for (const workspace of list) {
466
+ if (typeof workspace?.path !== "string") continue;
467
+ await ensureAcl(workspace.path, "sweep");
468
+ }
469
+ } catch (error) {
470
+ logger.warn(`sweep failed: ${String(error)}`);
471
+ }
472
+ }
473
+
474
+ // ---- 设置页接口 ----
475
+
476
+ function snapshotConfig() {
477
+ const { watchWorkspaces, deepGrant, retryCooldownMs } = readConfig();
478
+ return { watchWorkspaces, deepGrant, retryCooldownMs };
479
+ }
480
+
481
+ function snapshotWhitelist() {
482
+ return Object.keys(records)
483
+ .map((canonicalPath) => ({ path: canonicalPath, ...records[canonicalPath] }))
484
+ .sort((left, right) => (right.at ?? 0) - (left.at ?? 0));
485
+ }
486
+
487
+ // 新增走短路:已授权目录只登记不重跑 icacls。要强制重跑由「重新授权」按钮触发。
488
+ async function addPath(target) {
489
+ return ensureAcl(target, "settings-add", false);
490
+ }
491
+
492
+ function removePath(target) {
493
+ if (typeof target !== "string" || target.trim().length === 0) return false;
494
+ const canonicalPath = canonicalize(resolve(target.trim()));
495
+ const key = canonicalPath ?? resolve(target.trim());
496
+ hot.delete(key);
497
+ // 只移除本插件的记录,绝不动目录上已存在的任何 ACE。
498
+ if (key in records) {
499
+ delete records[key];
500
+ persist();
501
+ logger.info(`whitelist entry removed (no ACE revoked) ${key}`);
502
+ return true;
503
+ }
504
+ return false;
505
+ }
506
+
507
+ /** 变更类响应回带的目标路径;界面靠它把结果讲清楚。 */
508
+ function targetOf(body) {
509
+ const value = body?.path;
510
+ return typeof value === "string" ? value.trim() : "";
511
+ }
512
+
513
+ function sendJson(response, status, payload) {
514
+ const body = JSON.stringify(payload);
515
+ response.writeHead(status, {
516
+ "content-type": "application/json; charset=utf-8",
517
+ "cache-control": "no-store",
518
+ "content-length": Buffer.byteLength(body),
519
+ });
520
+ response.end(body);
521
+ }
522
+
523
+ function readJsonBody(request) {
524
+ return new Promise((resolveBody, rejectBody) => {
525
+ const chunks = [];
526
+ let size = 0;
527
+ request.on("data", (chunk) => {
528
+ size += chunk.length;
529
+ if (size > 64 * 1024) {
530
+ rejectBody(new Error("request body too large"));
531
+ request.destroy();
532
+ return;
533
+ }
534
+ chunks.push(chunk);
535
+ });
536
+ request.on("end", () => {
537
+ const text = Buffer.concat(chunks).toString("utf8").trim();
538
+ if (text.length === 0) { resolveBody({}); return; }
539
+ try { resolveBody(JSON.parse(text)); } catch (error) { rejectBody(error); }
540
+ });
541
+ request.on("error", rejectBody);
542
+ });
543
+ }
544
+
545
+ function trusted(request, response) {
546
+ if (isTrustedRequest(request, ctx.webRuntime?.trustedHosts ?? [])) return true;
547
+ logger.warn(`rejected untrusted request: ${String(request?.url ?? "")}`);
548
+ sendJson(response, 403, { ok: false, error: "forbidden" });
549
+ return false;
550
+ }
551
+
552
+ async function handleState(request, response) {
553
+ if (!trusted(request, response)) return;
554
+ try {
555
+ sendJson(response, 200, {
556
+ ok: true,
557
+ platform: process.platform,
558
+ supported: IS_WINDOWS,
559
+ stateFile: stateFilePath(),
560
+ config: snapshotConfig(),
561
+ whitelist: snapshotWhitelist(),
562
+ });
563
+ } catch (error) {
564
+ sendJson(response, 500, { ok: false, error: String(error) });
565
+ }
566
+ }
567
+
568
+ function makeMutation(handler) {
569
+ return async (request, response) => {
570
+ if (!trusted(request, response)) return;
571
+ if (request.method !== "POST") {
572
+ response.writeHead(405, { allow: "POST" });
573
+ response.end();
574
+ return;
575
+ }
576
+ try {
577
+ const body = await readJsonBody(request);
578
+ const result = await handler(body);
579
+ sendJson(response, 200, { ok: true, ...result });
580
+ } catch (error) {
581
+ sendJson(response, 500, { ok: false, error: String(error) });
582
+ }
583
+ };
584
+ }
585
+
586
+ // 路由注册:依赖已由静态 inject 保证就绪,直接取 ctx.webServer 并由 ctx.effect
587
+ // 持有注销器;单条路由注册失败只记日志,不带垮整个插件。
588
+ // 变更类路由一律回带目标路径:客户端要据它把结果讲清楚(命中跳过态时不会入库,
589
+ // 单看列表会以为什么都没发生)。
590
+ const routes = [
591
+ { kind: "exact", path: `${ROUTE_PREFIX}/state`, handler: handleState },
592
+ {
593
+ kind: "exact",
594
+ path: `${ROUTE_PREFIX}/add`,
595
+ handler: makeMutation(async (body) => ({ path: targetOf(body), entry: await addPath(body?.path) })),
596
+ },
597
+ {
598
+ kind: "exact",
599
+ path: `${ROUTE_PREFIX}/regrant`,
600
+ handler: makeMutation(async (body) => ({
601
+ path: targetOf(body),
602
+ entry: await ensureAcl(body?.path, "settings-regrant", true),
603
+ })),
604
+ },
605
+ {
606
+ kind: "exact",
607
+ path: `${ROUTE_PREFIX}/remove`,
608
+ handler: makeMutation(async (body) => ({ path: targetOf(body), removed: removePath(body?.path) })),
609
+ },
610
+ ];
611
+
612
+ ctx.effect(() => {
613
+ const disposers = [];
614
+ for (const route of routes) {
615
+ try {
616
+ disposers.push(ctx.webServer.register(route));
617
+ } catch (error) {
618
+ logger.warn(`route ${route.path} was not registered: ${String(error)}`);
619
+ }
620
+ }
621
+ logger.info(`${ROUTE_PREFIX} routes registered (${disposers.length}/${routes.length})`);
622
+ return () => { for (const dispose of disposers) dispose(); };
623
+ }, "workspace-acl-allow: routes");
624
+
625
+ logger.info(`ready (state: ${stateFilePath()})`);
626
+ void sweepRegistered();
627
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "dsh-workspace-acl-allow",
3
+ "version": "0.1.0",
4
+ "description": "DeepSeek Harness 插件:为用户显式声明的工作区目录预置 Windows ACL 完全控制 ACE,补上沙箱授权所需的 WRITE_OWNER 前提,消除危险模式弹框。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "cordis.patch.yml",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "publishConfig": {
19
+ "registry": "https://registry.npmjs.org/"
20
+ },
21
+ "license": "MIT",
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "os": [
26
+ "win32"
27
+ ],
28
+ "dsh": {
29
+ "bundle": {
30
+ "patch": "./cordis.patch.yml"
31
+ },
32
+ "client": {
33
+ "platform": "web",
34
+ "inject": [
35
+ "@deepseek-ai/dsh-client-ui-slots",
36
+ "@deepseek-ai/dsh-client-ui-plugin-manager"
37
+ ]
38
+ }
39
+ },
40
+ "peerDependencies": {
41
+ "@deepseek-ai/cordis": "*",
42
+ "@deepseek-ai/schemastery": ">=3.18.3 <4.0.0",
43
+ "@deepseek-ai/dsh-storage-domain": "*"
44
+ },
45
+ "scripts": {
46
+ "build": "node scripts/build.mjs",
47
+ "prepublishOnly": "npm run build"
48
+ }
49
+ }