dsh-crwu-workbench 0.0.14 → 0.0.15

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/CHANGELOG.md CHANGED
@@ -7,6 +7,226 @@
7
7
  `cordis_define` + `cordis_run` 装配,版本号用 DSH 的 `pkg-N`);它已在本仓收尾时删除
8
8
  (见 `0.0.1` 一节),下面 `legacy · pkg-43` 及更早的记录是它的历史。
9
9
 
10
+ ## package · 0.0.15 · 2026-09-28
11
+
12
+ **协议 18:本机访问从「调用方声明提权」改成「具名操作 + 版本化授权收据」,
13
+ 审核会话固定收敛到 `workspace-write` + `approval=never`。**
14
+
15
+ 这是**跨进程契约的语义破坏**,不只是加字段:旧的 `trust` 布尔不再被接受(协议不匹配直接失败,
16
+ 不会被当成"已授权"),`crwu` 直通 RPC 不再接受 `escalate` 参数,提权改由**操作身份**决定。
17
+ 装上升级后需要**完整退出并重启** DSH(客户端与宿主是两个进程,只刷新页面会留下旧宿主)。
18
+
19
+ ### 为什么要换形态(员工实测的三段报错)
20
+
21
+ | 员工看到的 | 真正的原因 | 旧形态为什么分辨不出 |
22
+ | --- | --- | --- |
23
+ | 写 `%USERPROFILE%\.ossutilconfig` 报 `file access denied under workspace-write mode` | DSH 沙箱拒绝 | `escalate: true` 只是一个布尔,看不出"这一步到底放行了什么" |
24
+ | 氚云报 `secret not found in keyring` | 受限沙箱读不到钥匙串,**不是**没登录 | 未授权时会先探一次凭据,把假结论当真相 |
25
+ | DWS 报 `.data.lock: Access is denied` | 沙箱写不了 / 真 NTFS ACL / 锁被占用(三种) | 三者文本一样,只能靠猜 |
26
+
27
+ 三段表象不同、原因相同;而同**一句** `Access is denied` 又可能是三种完全不同的原因。
28
+ 所以这一版把"提权"变成一个**具名操作的属性**,把"为什么失败"变成**结构化事实的推理**。
29
+
30
+ ### A · 版本化同意 + 安全的环境引导(协议 18)
31
+
32
+ - 授权收据带 `schemaVersion` + `grantedAt` + **固定的五项能力**;旧的 `trustCredentials: true`
33
+ 一律判为 `outdated`(**永远不是**一次授权)。
34
+ - **未授权时一个凭据进程都不起、一个凭据字节都不读**(原来会先探一次,于是"未登录/密钥错误"
35
+ 这种假结论会被写进交付件)。未授权在环境自检里是**阻塞项**,不是"未登录"。
36
+ - 授权入口在环境 workflow 的「账号连接」里(不是遮住整页的模态框),并带一次明确的「暂不允许」。
37
+
38
+ ### B · 本机访问代理(Local Access Broker)
39
+
40
+ - `src/host/access/{operations,diagnostics,broker}.ts`:**封闭的操作表**(27 个操作,
41
+ 每个固定 capability / 通道 / 是否提权 / 允许的来源)+ 唯一能声明 `danger-full-access` 的地方。
42
+ - `runShell` **没有** `escalate` 参数;调用方只能选一个操作名,不能提交命令、二进制路径、
43
+ 沙箱模式或提权开关。文件写入还要求"目标种类 + 绝对路径"逐字对上。
44
+ - 全量迁移:状态文件、iFinD 凭据、OSS 配置与**每一次** `ossutil`、氚云全部子命令、
45
+ `dws` 全部白名单前缀、浏览器/剪贴板/案例目录、13 处审核 Tool 调用点。
46
+ - **归因只看结构化事实**:`requested` / `resolved` / `ran` / `denied` / `runnerFailed`。
47
+ `ran` 也要看 —— 请求活过了 `resolve()` 却在执行时被降级是最难发现的一种。
48
+ - 迁移守卫(`host-access-migration.test.mjs`):业务 CLI 只能在登记的执行器里拼命令、
49
+ 且必须经 Broker;这条守卫在开发中真的抓到两处自己拼 `dws` 命令的调用点。
50
+
51
+ ### C · 审核会话收敛
52
+
53
+ - 审核根与审核子会话**永远** `workspace-write` + `approval=never`:用 DSH 自己的
54
+ `setSandboxMode` / `setApprovalPolicy` 写入,再**回读**,并按 DSH 的委派捕获口径确认
55
+ "子代理将要继承到什么"。**不依赖部署默认**(默认可能是 `danger-full-access`)。
56
+ - 带 `auto` / `danger-full-access` preset 的根**不可复用**;创建子代理前还有一道环境就绪门禁;
57
+ 子会话发布后按它自己的 scope 复查策略与工具可见性,不对就**停掉它**。
58
+ - 登录类操作(`h3yun.session.login` / `dws.auth.login`)的 `allowedSources` **只有面板**:
59
+ 审核链路连登录这个动作都拿不到。提示词里明令"未登录就停下、要员工回工作台"。
60
+
61
+ ### D · 桌面归因与修复指引
62
+
63
+ - `src/host/access/classify.ts`:九档优先级的**纯函数**分类器,把三段表象分开成
64
+ `os-credential-store` / `file-lock` / `os-filesystem-permission` / `sandbox-denied` /
65
+ `sandbox-downgraded` / `infrastructure` / `cli`。
66
+ `sandbox-exec: sandbox_apply: Operation not permitted` **永远**是基础设施故障,
67
+ 不会被翻译成"命令缺失 / 没登录 / 凭据无效";锁必须有**正向探测**才定性。
68
+ - 新 Host 操作 `dws-local-doctor`(只读、零参数、目录由 `<home>/.dws` 推导)与
69
+ `dws-local-permission-repair`(**只给面板**、必须 `{ confirm: true }`、要求体检**正向确诊**
70
+ "本机文件权限问题")。修复只动 `.dws`(700)与 `.data.lock`(600):不 `chown` / `takeown` /
71
+ `sudo`、不删锁、不碰父目录、拒符号链接,修完**重新体检**。
72
+ - 界面:修复按钮**只在确诊本机文件权限问题时渲染**,并且要**二次确认**(改权限 ≠ 允许读本机凭据)。
73
+ 沙箱拒绝 / 降级 / 钥匙串 / 认证失败 / 所有者不对 / 文件锁都不渲染。
74
+ - `@deepseek-ai/dsh-sandbox-policy` 与 `@deepseek-ai/dsh-user-approval` 加入 peer + dev
75
+ (与其余 7 个 peer 同口径;宿主产物会 `import` 它们,接收方由 DSH 运行时提供)。
76
+
77
+ ### 操作清单:33 → 38
78
+
79
+ 新增 `local-access-grant` / `local-access-revoke`(A)、`access-diagnostics`(B,只读)、
80
+ `dws-local-doctor` / `dws-local-permission-repair`(D)。`crwu` 直通入口的 `escalate` 参数已删除。
81
+ 冻结清单在 `tests/helpers/frozen-inventory.mjs`,漏登记会直接红。
82
+
83
+ ### 复查期间加固(2026-09-29,用户复查逐条点出后修)
84
+
85
+ 下面这些**都在本版里**(0.0.15 未发布,所以按最终形态记),每一条都能被具名用例证伪:
86
+
87
+ - **授权/撤销的状态迁移只由 `host/access/consent.ts` 负责**:调用方不得把返回的收据写回活状态 ——
88
+ 写盘失败时那份视图可能来自**磁盘上的旧授权**,照抄等于一次失败的「重新允许」把权限重新打开。
89
+ - **案例目录必须落在选定工作空间的信任域内**:此前这条要求只写在注释里。`caseDir` 是模型参数,
90
+ 只校验"绝对 + 存在 + 是目录 + 没有 `..`"等于没校验 —— `crwu_audit_oss_publish` 会把**任意可读文件**
91
+ 传到 OSS(读不受沙箱限制),`crwu_h3yun_record_get` / `files_list` 更把它**原样当特权命令的 cwd**。
92
+ 现在 `allowedRoot` 是**必填参数**(编译器保证没有调用点能漏传),运行时漏传 fail closed。
93
+ - **信任域未知一律拒绝,不许"跳过检查继续做"**:`openPath` 曾经是后者,那天唯一挡住它的是
94
+ "提权必须有 workdir"与案例根兜底**恰好相等**;判据现在落在任何 fs 探测之前。
95
+ - **锁归因要两个条件**(原始失败与锁有关 **且** 正向探测证明有人持有);Windows 的锁探测必须沿
96
+ `InnerException` 解包到根异常再读 HResult(旧写法让"真有进程持锁"也落 `Unknown`,W-05 在代码上不可达)。
97
+ - **目录与 `.data.lock` 分别探权限**:只探目录会让"目录正常、锁文件不可写"退化成 `cli`、修复入口永不出现;
98
+ 修复只动被证明有问题的对象,回读也只核对修过的那些。
99
+ - **Windows ACL 算有效权限**:`Modify = 197055` / `FullControl = 2032127`(夹具曾把后者标成 Modify);
100
+ 判据是"所需**写**权限"是否被完整覆盖,**部分 Deny**(`(W)`、Delete)同样算不可写 —— 旧实现整个忽略。
101
+ - **脱敏声明要有端到端证据**:取数摘要的净化此前只在失败路径被喂过 token,成功路径只试过 `{"v":1}`;
102
+ 现在按"零件级 + 接线级"两条分别取证(对象路径的密钥字段整条丢 ≠ 文本的值级净化)。
103
+ - **卸载路径此前从未被执行过**:`smoke:built` 现在调用每个 `ctx.effect` 的 disposer,断言同源路由被摘、
104
+ 工具被逐个注销(忘 `return` disposer 是 Cordis 的经典坑,本地以前完全看不见)。
105
+
106
+ ### 复查第二轮:**审核 scope 绑定**(协议 19)
107
+
108
+ 第一轮修的是"案例目录必须在工作空间之下"。用户第二轮复查指出:这**不够** ——
109
+ 一个工作空间里通常有很多案例目录,于是 S1 的子会话可以传 `<工作空间>/S2`、
110
+ 或传工作空间根再读 `S2/文件`(把别的案例读出来传上 OSS、或往别的案例里写);
111
+ 氚云的 `objectId` / `fileId` 也是模型提交的,Host 没有把任何东西绑到**本轮**审核上。
112
+
113
+ - **Host 权威 scope 落盘**:创建记录时写死 `casePath`(= `<工作空间>/<流水号>`)、`attemptId`
114
+ 与 `allowedAttachmentIds`(来自**可信输入快照**),跨重启保留。认领来的旧记录 scope 不完整 →
115
+ 案例内 Tool 一律拒绝(不拿工作空间兜底)。
116
+ - **统一门禁 `requireAuditScope`**:按 `exec.agent.id → childId → 记录` 找本轮 scope,
117
+ `caseDir` 必须与 `casePath` **规范解析后精确相等**(工作空间根、兄弟案例、案例目录的子目录一律拒绝;
118
+ Windows 盘符/UNC 按风格大小写不敏感,POSIX 大小写敏感),`seqNo` / `objectId` 必须一致,
119
+ 无身份 / 未知 childId / 已结束 / scope 不完整一律 fail closed。通过时**返回 Host 的路径**,
120
+ 调用方不再使用模型给的字符串。
121
+ - **氚云边界收紧**:`crwu_h3yun_record_get` / `files_list` 移出审核子会话的必需集
122
+ (注册面与审核能力集拆成 `CRWU_BUSINESS_TOOLS` / `REQUIRED_AUDIT_TOOLS`),
123
+ 审核子会话调用它们直接拒绝且**零进程**;`crwu_h3yun_file_get` 只接受快照里登记的 `fileId`,
124
+ 表外 id 在起进程之前拒绝。`case_bootstrap` 也要求 `caseDir` 精确等于 Host 约定算出的那一个。
125
+ - **真正的写边界是案例目录**:审核根的 `cwd` 与 `workspace-write.workspaceRoot` **都**改成
126
+ 本轮案例目录(回读核对),一条根只服务一个案例,工作空间级旧根(`casePath` 为空)判**过期**不复用;
127
+ 占用门禁提前到"建目录 / 建根"之前,所以被拒的发起**零副作用**(不建空目录、不起根会话)。
128
+ 于是通用 shell / fs 也只能写自己的案例目录 —— 这一条与设计文档"只能写自己的案例目录"终于一致。
129
+ - **协议 18 → 19**:这是跨进程权限语义变化,旧宿主仍按工作空间级边界跑审核,
130
+ 必须靠协议号把"界面新、宿主旧"拦下来(客户端会要求完整重启)。
131
+ ### F 段:可观察的停止状态机 + 当前审核会话入口(2026-09-29)
132
+
133
+ - **两阶段停止协议**:`audit-stop` 立刻把阶段写成 `requested` 并落盘后返回"已接受",
134
+ abort → dispose → 静默复查在后台继续,阶段逐段落盘;`audit-status` 每条记录带
135
+ `stop`(`phase` / `requestedAt` / `elapsedMs` / `quiesced` / `aborted` / `disposed` /
136
+ `error` / `notes` / `canStartNext`),并加一个顶层 `canStartNext`。
137
+ 重复点停止**幂等**(`alreadyStopping`,不启动第二条流程、不重复 abort)。
138
+ - **阶段枚举**:`idle` / `requested` / `aborting` / `waiting-quiescence` / `quiesced` / `timeout` / `failed`。
139
+ `canStartNext` **只由 Host 判定**:没确认静默(timeout / failed / 进行中)一律 false,
140
+ 且**不依赖占用锁在不在**(重启后锁可能没恢复,"没确认停下"仍然成立)。客户端缺字段按未知处理。
141
+ - **启动门禁**:停止流程在跑、或上一条留下 timeout / failed 时,`audit-start` 直接拒绝;
142
+ 界面同时把行上的「AI 审核 / 重新审核」置灰(与 Host 的 `canStartNext` 取交)。
143
+ - **界面(F2/F3)**:顶部「当前审核」摘要卡(流水号 / 项目 / 状态 / attempt / 开始时间 /
144
+ childId 脱敏尾部 / 停止阶段 / 观察),**在表格之外**,所以当前审核不在当前分页或筛选结果里时
145
+ 依然可见;阶段文案用 `aria-live="polite"` 播报、等待期间显示"已等待 N 秒 · 最多等待约 8 秒"、
146
+ 停止按钮禁用;timeout 给「继续等待 / 再次停止 / 复制诊断」,failed 给「打开当前会话 / 复制诊断」
147
+ 并显示真实错误与"当前审核仍被保留,未释放占用"。
148
+ - **「打开审核会话」**:用**被点那一行**的 `childId` / `parentSessionId`(`openSessionTarget` 是唯一解析点),
149
+ `childId` 为空时按钮显示「会话正在建立」并禁用;它与「AI 审核结果分析(`audit_analysis`)」
150
+ 是两个入口、两个目标会话(文案与目标都有断言)。
151
+ - **客户端停止口径是纯函数**(`features/report-audit/stop-view.ts`):阶段文案 / 语气 / 可用动作 /
152
+ `canStartNext` 都能被单测穷举(7 条),组件只负责渲染。
153
+ - **协议号仍是 19**:新增的 `stop` / `canStartNext` 都是**可选**字段 —— 旧客户端忽略它们照常工作,
154
+ 新客户端在旧宿主上把它们当"未知"(不显示"已停止"、也不放行下一条)。没有破坏性契约变化。
155
+
156
+ ### 第三轮复查:4 个 P1 + 2 个 P2(全部已修,逐条有用例与缺陷注入)
157
+
158
+ - **子会话必需集与 deny 集重叠(P1)**:`crwu_audit_case_bootstrap` 既是"根必需"又是"子会话 deny",
159
+ 而子会话复查用的是根必需集 —— 真实 `toolFilter` 生效后**每条正常子会话都会被判成缺工具并停掉**。
160
+ 现在拆成 `REQUIRED_AUDIT_TOOLS`(根必需)与 `REQUIRED_AUDIT_CHILD_TOOLS`(子会话必需 = 根必需 − deny),
161
+ 并有一条门禁断言两个集合**不相交**;子会话复查专用后者。
162
+ - **pending scope 按父会话认领会允许 sibling 冒领(P1)**:父会话 id 只能证明"属于同一个 root",
163
+ 证明不了"就是本次 `start()` 创建的那一个"。one-shot `start()` 没有预留 child id 的参数、
164
+ 拿不到不可伪造的 launch token,所以窗口内**案例内 Tool 一律 fail closed**(
165
+ `auditScopeFor` 只认与记录逐字相等的 childId);父会话判据只保留在**拒绝方向**(`isAuditChild`)。
166
+ - **失败分支忽略 `quiesced`(P1)**:子会话可见性/策略复查失败、最终落盘失败这三条路径此前直接
167
+ 回滚记录与句柄;dispose 超时且 Agent 仍 running 时,会形成"旧子会话继续写案例目录、Host 已抹掉身份"。
168
+ 现在检查 `quiesced`:没确认静默就写成**退役记录**(`retired: true`,只拒绝不放行)、
169
+ **保留** run 句柄与占用,并提示去工作台重试停止。
170
+ - **读不到 child Agent 仍继续审核(P1)**:`SubagentRun.localAgent` 对远程 provider 是 `undefined`,
171
+ 而边界复查全靠它。现在保留并优先使用 `localAgent`,**读不到就停掉并失败**(不再只记 warning);
172
+ `pickProvider` 也**不再回退到"第一个注册的"**,只接受本地可验证的 provider(`spawn` / `fork`)。
173
+ - **orphan pending 跨重启可冒领(P2)**:创建失败且回滚写盘也失败时,磁盘会留下 `pending: true` 的记录。
174
+ 恢复时把它**退役**(不发放 scope),并且**不恢复指向空 childId 的占用锁**。
175
+ - **`auditStop` 返回写死的停止结果(P2)**:现在透传真实的 `StopOutcome`
176
+ (`aborted` / `disposed` / `quiesced` / `interrupted` / `agentCancelled` / `notes`)。
177
+
178
+ ### 第二轮复查:5 个 P1 + 2 个 P2(全部已修,逐条有用例与缺陷注入)
179
+
180
+ - **子会话策略复查用了错的边界**(P1):协议 19 已把子会话的边界/cwd 收紧到案例目录,
181
+ 但复查仍拿整个工作空间当期望值 —— **正常可见的子会话会被稳定判错并停掉**。
182
+ 现在传 `caseDir`,并新增"真实可见 child + 正确案例目录能正常启动"的**正向**用例
183
+ (旧夹具的 `agents.get()` 只返回根 Agent,整段复查被跳过,所以这条一直没被抓到)。
184
+ - **发布后的子会话没有被真正限制工具**(P1):`record_get` / `files_list` / `case_bootstrap`
185
+ 仍可见可执行 —— "必需集名单"只是预检名单,不是边界。现在按 provider 的
186
+ `capabilities.toolFilter` 显式传 `toolFilter.deny`(DSH 在子会话创建窗口里做 scoped
187
+ `tools.restrict()`:**既不进 prompt、也拒绝执行**),**不支持过滤的 provider 在创建子会话之前拒绝**。
188
+ - **子会话发布早于 scope 建立**(P1):`subagents.start()` 返回前子会话已在跑,那段窗口里
189
+ 身份拒绝失效、案例内 Tool 随机失败。现在**两阶段握手**:创建子会话**之前**先落一条
190
+ pending 记录(父会话 = 审核根),窗口内按父会话认领本轮 scope;`start()` 返回后写真正 childId;
191
+ 创建失败 / 子会话复查失败一律回滚 pending。
192
+ 窗口内的"父会话"有**两条来源**:会话头的 `meta.parentSession`,读不到就问 `subagents.listChildren`
193
+ (会话存储驱动,Host 权威)"审核根的孩子里有没有这个调用者" —— 两条都拿不到才 fail closed。
194
+ 这样一来该判据不再依赖另一个组件是否写了元数据。
195
+ - **dispose 超时被当成停止成功**(P1):计时器是**正常 resolve** 的,旧实现在超时时
196
+ `disposed=false` 但 `errors=[]`,上层据此继续重启。现在区分"谁赢了",并以**静默判据**
197
+ `quiesced`(dispose 完成 **或** Agent 已不在 running;问不到状态 = fail closed)为准:
198
+ 没确认停下来就**不覆盖旧记录、不启动下一条**,句柄与占用一并保留。等待也改成**有界**
199
+ (没有 timer 服务时退化成全局定时器,绝不无界 await)。
200
+ - **返回记录的身份没有核对**(P1):`records get --id B` 取回一条记录 ≠ 取回的是 B。
201
+ 现在**在请求附件与任何写盘之前**核对返回记录的 `ObjectId` / `SeqNo`:缺字段或与请求不一致
202
+ 一律 fail closed(不再用调用方提交的值兜底)。
203
+ - **把不透明的 `targetKey` 当路径解析**(P2):`FsTarget.targetKey` 是 `Branded<'FsTargetKey'>`,
204
+ 合同只允许等值比较;旧实现做分隔符/大小写归一化,形似 Windows 路径但区分大小写的后端会被错误放行。
205
+ 现在只做 `===`(`displayPath` 不再当身份用);我们自己产生的路径字符串另有 `samePathText`。
206
+ - **审核记录持久化失败仍返回成功**(P2):启动/停止/释放三条路径都处理 `persistAudits=false` ——
207
+ 启动失败会停掉刚起的子会话并回滚内存锁;停止/释放如实返回失败(内存放开了,但磁盘上可能还留着)。
208
+
209
+ (实现方在交付前自己找的,均已用缺陷注入证伪):
210
+ ① `record_get` / `files_list` 的拒绝条件一度写成"这轮 scope 是否可用" —— 一轮审核
211
+ `ended: true` 之后 scope 变为不可用,那个还活着的子会话就能以"没有可用 scope"为由绕过拒绝、
212
+ 去读**任意** `objectId`;判据已改成**调用者身份**(`isAuditChild`,不要求 scope 可用)。
213
+ ② `crwu_audit_case_bootstrap` 的 `caseDir` 虽被钉在本轮案例目录,但 `objectId` 仍是提交进来的 ——
214
+ 子会话可以用**别的** objectId 把别人的记录取进自己的案例目录;现在审核子会话调用它一律拒绝。
215
+
216
+ ### 测试
217
+
218
+ 新增/扩写的测试文件:`host-access-consent` / `host-access-broker` / `host-access-classify` /
219
+ `host-access-migration` / `host-access-rpc-gate` / `host-audit-policy` / `host-dws-local` /
220
+ `client-dws-local` / `host-case-dir-gate` / `host-design-traceability`(**共 1242 条**,
221
+ 本机 `1242 / 1238 pass / 1 skip`;两条 posix 子用例只在"用 DSH 自带二进制当 `node`"时红,
222
+ 机制见 `docs/review-0.0.15.md` §5)。
223
+
224
+ 每一条新断言都用**缺陷注入**证伪过(注入 → 变红 → `cp` 还原 → 转绿);
225
+ 开发与复查过程中被抓到的真实缺陷包括:OSS 未授权仍然报「还没有填写 AccessKey」、
226
+ 环境自检与身份查询自己拼 `dws` 命令绕开白名单、审核根的诊断被体检自己挤掉、
227
+ "没确认"的修复调用照样跑了一串探测、撤销写盘失败反而把权限重新打开、
228
+ 案例目录可以指到工作空间之外、Windows 锁探测读错异常层。
229
+
10
230
  ## package · 0.0.14 · 2026-09-28
11
231
 
12
232
  **兼容 DSH `0.2.0-rc.1`:peer 区间从「一条线」改成「两条线并列」。**
@@ -201,6 +421,38 @@ UnexpectedToken`),相关按钮点下去也不会有结果;macOS 上完全
201
421
  - 文档:`docs/windows-acceptance.md` 的收尾步骤在 PowerShell 里错用了 `rm -rf`,
202
422
  改为 `Remove-Item -LiteralPath … -Recurse -Force`。
203
423
 
424
+ #### 工作区外路径与沙箱归因(同一版本的第四批,2026-09-28 员工 Windows 实测)
425
+
426
+ 员工在 Windows 上报了三段看起来无关的错误,实际是同一个原因:**受限沙箱(`workspace-write`)
427
+ 不允许碰工作区之外的路径**(`%USERPROFILE%` 下的 `.ossutilconfig`、`.dws\`、操作系统凭据存储)。
428
+ 当时插件有两处该提权却没提权,还有一处把沙箱下的假结论当成了真结论。
429
+
430
+ - **修①|写 `~/.ossutilconfig` 被沙箱拒绝**:`oss/ops.ts` 的 `fs.writeText` 没声明
431
+ `sandboxPolicy`(同仓的 `ifind/store.ts`、`state/persist.ts` 都声明了),员工实测原文
432
+ `cannot write "C:\Users\<用户>\.ossutilconfig": file access denied under workspace-write mode`。
433
+ 现在与那两处同一形态。
434
+ - **修②|`crwu h3yun session status` 拿不到真结论**:提权白名单只放行了 `session login`,
435
+ 而 `status` / `bind` 才是**读**钥匙串的那两条。受限沙箱下读不到,crwu 如实回
436
+ `secret not found in keyring` —— 面板照着显示成「未登录」,把人指去重新扫码。
437
+ 现在 `h3yun session` 整段放行(凭据存储的读与写都要在沙箱外)。
438
+ - **修③|未授权时不再去问氚云**:环境探测原来无条件跑一次 `h3yun session status`,
439
+ 既是假结论、又永远拿不到真值。现在与钉钉那条同一条纪律:未授权就报「需要授权」并进阻塞项,
440
+ **一条凭据命令都不发**;授权后才带 `sandboxPolicy` 去拿真结论。
441
+ - **新增归因**:`shell/run.ts` 的 `sandboxDenialNote()` 统一认三种表象 ——
442
+ DSH 的标记 `file access denied under <mode> mode`(确定)、
443
+ 以及 `Access is denied` + 工作区外路径线索(**疑似**,措辞不把猜测说成结论)。
444
+ `runDws` / `runCrwu` 会把这句话补在错误最前面,于是 `dws` 的
445
+ `acquiring file lock: … .dws\.data.lock: Access is denied.` 不再看起来像 dws 自己的 bug。
446
+ 测试样本**逐字抄自员工贴回来的原文**。
447
+ - **收下 DSH 的沙箱事实**:`ShellRunResult.sandbox = { mode, denied, runnerFailed }` 会说明命令
448
+ **实际**跑在哪个模式、沙箱是否真的拒绝过;`resolve()` 回来的 `spec.sandboxPolicy` 还能看出
449
+ 提权请求有没有被降级。`ShellResult` 现在把这四项(请求 / 解析 / 实际 / 是否被拒)一起带出来,
450
+ `sandboxDenialNote()` **以事实为准、事实在手就不再猜文本** ——
451
+ 于是「沙箱拒了」与「文件真被占用/ACL 异常」不会再被混为一谈(后者按「去授权」处理是修不好的)。
452
+ - 文档:`README.md` 的 Windows 故障排查与 `docs/development-notes.md` §5.1/§5.2 补上
453
+ 「工作区外的路径清单」「三种表象→同一个原因」「要全局关沙箱只能改 DSH 侧
454
+ (`DSH_PERMISSION_MODE=danger-full-access` 或 profile 的 `dsh-sandbox-policy.mode`)」。
455
+
204
456
  ## package · 0.0.12 · 2026-09-28
205
457
 
206
458
  **首个包含自更新能力的正式版本。** 0.0.10 / 0.0.11 用户需要**手动完成一次**引导升级
package/README.en.md CHANGED
@@ -71,9 +71,11 @@ Building the tarball yourself (from the `crwu-ai` repository): `make plugin-pack
71
71
  **② Bundled components** only ever reports "package incomplete / platform unsupported" if one is missing.
72
72
  Section **③ DSH script runtime** reports the **DSH-bundled** Python (with its `openpyxl` and other
73
73
  package versions); the system `python3` is not a dependency and is never used as a fallback.
74
- 3. To dispatch an audit, register the parent from a **top-level** session header first. Audits may only
75
- be parented by a top-level session; nesting them is what used to make status tracking lose track of
76
- a running child.
74
+ 3. To dispatch an audit, just click **AI audit** on the report-audit page. The plugin creates its own
75
+ **audit subagent root session** inside the selected workspace and parents the audit child to *that*
76
+ root — not to whichever chat session you happen to be looking at. The session-header
77
+ "register as sub-session parent" button now only passes the **preset of the session you are using**
78
+ to the audit root (so the audit toolchain matches your manual runs); it is **not** a prerequisite.
77
79
 
78
80
  > **The Skills ship with the package, organized in layers**: the tarball carries `skills/crwu/`
79
81
  > (27 in-repo Skills), `skills/dws/` (14 vendored `dingtalk-workspace-cli` Skills) and `common/skills/`
@@ -85,12 +87,21 @@ Building the tarball yourself (from the `crwu-ai` repository): `make plugin-pack
85
87
  > layer is upgraded.
86
88
 
87
89
  > **Permissions**: no startup parameters are needed (`DSH_PERMISSION_MODE` stays untouched), but the
88
- > **first run requires one authorization** — the "trust this plugin to read local credentials" switch in
89
- > the login layer. It is written to the workbench state file (`trustCredentials`), so it survives
90
- > restarts. Without it the plugin cannot read the H3Yun session or the DingTalk login state and the
91
- > self-check blocks the gate — and it never reports a false "not logged in". Once authorized, only the
92
- > commands that genuinely read local credentials ask for unconfined execution; everything else stays in
93
- > the profile's default sandbox.
90
+ > **first run requires one authorization** — the "allow the workbench to reach local accounts and
91
+ > configuration" switch in the account-connection step. It is written to the workbench state file as a
92
+ > **versioned receipt** (`localAccess`: `schemaVersion` + timestamp + five fixed capabilities).
93
+ > Protocol 18 treats the old `trustCredentials: true` as `outdated` — **it is not a grant**. Upgrading
94
+ > from 0.0.14 therefore needs a **full quit and relaunch** (protocol 17 → 18 is a semantic break);
95
+ > refreshing the page only leaves the old host running. Without a receipt the plugin cannot read the
96
+ > H3Yun session or the DingTalk login state, the self-check blocks the gate, and **no credential
97
+ > process is started at all** — so it can never report a false "not logged in". Once authorized, each
98
+ > **named operation** decides for itself whether it needs per-call `danger-full-access`
99
+ > (`src/host/access/operations.ts`); callers cannot submit an escalation switch and neither can the
100
+ > model. Only operations that genuinely touch paths outside the workspace escalate (H3Yun session,
101
+ > DingTalk `~/.dws`, `~/.ossutilconfig`, the iFinD credential file, plugin state); package integrity
102
+ > checks, case-directory writes and opening the browser stay in the profile's default sandbox. Audit
103
+ > root and child sessions are always `workspace-write` with `approval=never`, and the account-connection
104
+ > step also offers a read-only `.dws` checkup with a second-confirmed permission repair.
94
105
 
95
106
  ### Updating CRWU (self-update, from 0.0.12)
96
107
 
package/README.md CHANGED
@@ -145,14 +145,30 @@ node <仓库>/install/browser-check.mjs \
145
145
  > (**不再用模态层遮住整页**)。
146
146
 
147
147
  - **这是一次性授权、长期有效**:写进工作台状态文件 `~/.dsh/crwu-workbench.json` 的
148
- `trustCredentials`,重启 profile 后仍然生效。
148
+ **版本化授权收据**(`localAccess`:`schemaVersion` + 时间 + 五项固定能力)。协议 18 起,
149
+ 旧版的 `trustCredentials: true` 一律判为 `outdated` —— **它不算一次授权**(防静默扩权)。
150
+ - **从 0.0.14 升级后必须完整退出并重启 DSH**:协议从 17 跳到 18,是**语义**变化
151
+ (见 [`CHANGELOG.md`](CHANGELOG.md) 的 0.0.15 一节)。只刷新页面会留下旧宿主,
152
+ 表现为「新的凭据活动被拦,提示完整重启」(验收项 P-18)。
149
153
  - **不授权 = 插件不可用**:环境自检把它算作阻塞项(「进入报告审核」被门禁挡住),
150
154
  并且**不会**谎报「钉钉没登录」——那正是没授权时沙箱读不到钥匙串的假象。
151
- - **授权之后才谈真假**:凭据类命令才带 `sandboxPolicy: { mode: 'danger-full-access', workspaceRoot }`
152
- 去拿真结论(已登录 / 未登录 / 未绑定 / 已过期)。
153
- - **最小权限**:只有确实读本机凭据的命令提权;插件包完整性核对(只 stat 包内文件)、
154
- `ossutil` 上传/列举、写案例目录、开浏览器等一律走 profile 的默认沙箱。
155
+ - **未授权时一个凭据进程都不起**:连"先探一下看看"都没有。所以未授权时看到的
156
+ 「未登录 / 密钥错误」不可能是真的结论,界面也不会这么说。
157
+ - **授权之后才谈真假**:每个**具名操作**在自己的定义里声明要不要逐次 `danger-full-access`
158
+ (`src/host/access/operations.ts`)——调用方提交不了提权开关,模型更不能。
159
+ - **最小权限**:只有确实碰工作区外路径的操作提权(氚云会话、钉钉 `~/.dws`、
160
+ `~/.ossutilconfig`、iFinD 凭据文件、插件状态文件);插件包完整性核对(只 stat 包内文件)、
161
+ 写案例目录、开浏览器等一律走 profile 的默认沙箱。
162
+ 注意 `ossutil` **也算**本机凭据访问(它每次都会读 `~/.ossutilconfig`),
163
+ 这是协议 18 相对 0.0.14 的口径修正。
155
164
  - 授权被部署的审批策略拒绝时,界面如实报「本机凭据读取被拦住」,并指回那个开关。
165
+ - **审核会话固定收敛**:审核根与审核子会话永远是 `workspace-write` + `approval=never`
166
+ (不跟部署默认走)。审核需要的本机凭据由上面的具名操作按授权提供,子代理自己既不需要、
167
+ 也不允许提权;未登录时它会**停下并要求员工回到工作台**,不会自己发起登录。
168
+ - **「钉钉本机目录」卡片**:环境页「账号连接」里可以只读体检 `<HOME>/.dws`
169
+ (模式位 / 所有者 / 可写性 / 锁 / 钥匙串状态)。只有确诊「本机文件权限问题」时才会出现
170
+ 「修复权限」按钮,并且要**二次确认**;它只改 `.dws`(700)与 `.data.lock`(600),
171
+ 不改上级目录、不夺所有权、不删锁、不结束进程。
156
172
 
157
173
  `DSH_PERMISSION_MODE=danger-full-access` 只在**开发自测**(§「真实验证」里那个临时 profile)才需要。
158
174
 
@@ -213,8 +229,10 @@ dsh plugin --profile web add dsh-crwu-workbench # 从 npm 取预构建产物
213
229
  然后到「交付与外部数据」里填**向管理员获取**的阿里云 AccessKey(iFinD API-Key 同页填写,
214
230
  是必需项,未通过会拦住受保护页面)—— `crwu` / `dws` / `ossutil` 三个命令**随插件自带**,
215
231
  不需要安装;**密钥只在这个页面上填**,不要贴进对话、也不要让 Agent 代填;
216
- 3. 要发起审核,先在**顶层会话**的会话头点「登记为子会话父级」——审核只允许挂在顶层会话下
217
- (只挂一层,嵌套会导致状态跟丢)。
232
+ 3. 要发起审核:在**报告审核**页点「AI 审核」即可。插件会自己在选定工作空间里建一个
233
+ **审核子代理根会话**,审核子会话挂在**它**下面(不再挂在你正在看的那个聊天会话下)。
234
+ 会话头的「登记为子会话父级」现在只用来把**你正在用的会话的 preset** 提示给审核根
235
+ (让审核的工具链与你手动跑时一致),**不是**发起审核的前置条件。
218
236
 
219
237
  > **技能随包发布,按层组织**:包里带三层技能 —— `skills/crwu/`(本仓自研 27 个)、
220
238
  > `skills/dws/`(上游 `dingtalk-workspace-cli` vendored 的 14 个钉钉技能)、`common/skills/`
@@ -519,6 +537,13 @@ npm 安装**已发布的 tarball** 时也会执行 `prepare`,而 tarball 里
519
537
 
520
538
  ### Windows 故障排查
521
539
 
540
+ > **权限边界(先读)**:插件**不能**给自己发常驻豁免。DSH 的提权是「逐次、需人工批准、
541
+ > 只对该次调用生效」,没有审批通道时 fail closed。插件能做到的是:① 对工作区外的路径逐次声明
542
+ > `sandboxPolicy`;② 见到沙箱拒绝就**如实报因**(而不是把它显示成「未登录 / 未配置」);
543
+ > ③ 用一次性的「同意并继续」授权覆盖插件自己发的凭据命令。要把整台机器设成不沙箱,只有改 DSH 侧:
544
+ > `DSH_PERMISSION_MODE=danger-full-access dsh --profile <profile>`(或 profile 里
545
+ > `dsh-sandbox-policy` 的 `mode`)—— 那是机器设置,不是本插件的默认口径。
546
+
522
547
  | 症状 | 先查什么 | 处置 |
523
548
  | --- | --- | --- |
524
549
  | 插件装上了、界面里什么都没有,启动日志干净 | 是否被 DSH 判为**不兼容**(`skippedBundles` / 插件管理器标「与 DSH `<版本>` 不兼容」并禁用) | 判据是 `peerDependencies`(**不是** `engines.dsh`):本包写 `^0.1.7-rc.2 \|\| ^0.2.0-rc.1`。升级 DSH 后出现就先升插件;应急用 profile 的 `compatibility.json` 精确豁免。跑 `npm run compat:dsh` 复现判定 |
@@ -527,6 +552,9 @@ npm 安装**已发布的 tarball** 时也会执行 `prepare`,而 tarball 里
527
552
  | 命令「成功」了但文件其实没动 | 是否被 `-ErrorAction SilentlyContinue` 之类吞掉 | 0.0.14+ 已去掉;`case-files.ts` 还会用 `ctx.fs.stat` 回读后置条件 |
528
553
  | 路径被截断、目录名变成一长串 | 是不是按 POSIX 规则拼/取本地路径 | 0.0.14+ 统一走 `shared/utils/local-path.ts`;安全判定走 `ctx.fs.check` 之外的 `fs.contains`,不用字符串前缀 |
529
554
  | 二进制启动报「不是有效的 Win32 应用程序」/ 缺 DLL | 发布形态的架构是否匹配(`win32-x64` vs `win32-arm64`) | `win32-arm64` 不支持(设计如此);`npm run bin:smoke` 会在 Windows runner 上真的启动一次发布二进制 |
555
+ | 报 `file access denied under workspace-write mode`(例如写 `C:\Users\<你>\.ossutilconfig` 或 `~/.dsh/`) | 那是 **DSH 沙箱**的拒绝标记,不是 Windows 权限 | 0.0.14 起插件对工作区外的落盘自己声明 `sandboxPolicy`;若仍出现,说明 profile 把提权请求降级了 —— 见下一条 |
556
+ | 环境页说「未登录」,但你在终端里 `crwu h3yun session status` / `dws auth status` 明明是登录的 | 受限沙箱里读不到系统凭据存储,命令会**如实**回 `secret not found in keyring` —— 那是假结论 | 点面板「同意并继续」完成**一次性授权**(长期有效),插件才会带 `sandboxPolicy` 去读;这就是「未授权时不许猜」 |
557
+ | 钉钉登录报 `acquiring file lock: opening lock file: open C:\Users\<你>\.dws\.data.lock: Access is denied.` | 两种可能:命令在沙箱里写不了 `~/.dws/`,**或**那个文件真的被占用 / 有异常 ACL | ① 先在面板授权后重试;② 仍失败就在**你自己的 PowerShell**(不受沙箱约束)里关掉残留的 `dws` 进程,必要时删掉 `%USERPROFILE%\.dws` 再登录 |
530
558
  | 凭据文件权限看不到「600」 | Windows 没有 POSIX 权限位、也没有 `chmod` | 界面会明说「使用当前 Windows 账户 ACL;POSIX 0600 不适用」(协议 17 的 `permission.status = inherited`)——这不是失败,也不是「已验证」 |
531
559
  | 安装/`prepare` 报找不到 `tsdown` 或路径被拆坏 | 构建命令是否经过 shell | 0.0.14+ 用 `process.execPath` 直接执行 tsdown 的 JS 入口(`scripts/lib/cli-entry.mjs`),**不经过 shell**,所以路径里的空格与单引号不会被改写 |
532
560