# ADR-0017: 附加根三态权限与跨 SandboxMode 执行一致性(B9) ## Status Accepted(Phase 1 定案,2026-10-13;Phase 2–4 实现已落地,2026-10-14;原生平台执行证据待补,未完成前不得宣称全平台成立) ## Date 2026-10-13 ## Context ADR-0013 的两档 `access`(`read-write` / `read-only`)把全局模式的边界原样继承到附加根:`read-only` 会话全拒写,`danger-full-access` 全放行。B9 要求把登记策略变成三档并让显式策略覆盖会话模式的默认边界:显式只读不得被 Full-access 绕过,显式读写在全局只读会话下仍授予该根写权限。 Phase 1 对全部 11 个支持版本(`0.1.5-rc.2` … `0.2.1-alpha.2`)的执行面做了逐项源码核对,得到三条决定性事实: 1. **Full-access 在每个进程消费面都绕过 `ctx.sandbox.confine()`**。`SandboxBashExecutor.execute` / `SandboxPwshExecutor.execute`(`0.1.5/0.1.6` 还有 `start`)在 `mode === 'danger-full-access'` 时直接调本地执行器;`dsh-terminal-bash` 的 `spawnArgv` 是模块私有函数,同条件返回裸 argv;`dsh-ptc-runtime-node`(run_code)同样 `mode === 'danger-full-access' ? void 0 : await ctx.sandbox.confine(...)`。因此 B9 不可能只在 `ctx.sandbox` 内实现。 2. **`subprocess` spec 不携带 `sandboxPolicy`**,但 PTY spawn env 携带 `DSH_SESSION_ID = spec.owner.id`,而 `Agent.enter` 强制 `agent.id === agent.session.id`——共享的 `ctx.subprocess.spawnTerminal` 是可用的公开接缝(`persistent-shell` 组 `isolate: terminals`,组内 backend 不可达,但它委托共享 subprocess 服务)。PTC 子进程 env 被构造为全空加显式注入,没有任何会话标记。 3. **方言能力不对称**:现有「观测克隆追加 allow」架构只表达加根,表达不了 Full-access 下的局部拒写。SBPL last-match-wins 使 `(allow default)` 后追加 `(deny file-write* (subpath R))` 成立;bwrap `--bind` 之上再 `--ro-bind` 成立;Landlock 上游启动器仅 `--ro`/`--rw` 单层 allowlist;Windows runner 的单一 root-set SID 只有 allow 维度。 Phase 1 时本机外层沙箱拒绝 `sandbox_apply`,原生证据留待补齐;Phase 2–4 落地后已于 macOS 直接验证 Seatbelt 两种构造(见下表)。bwrap / Landlock / Windows ACL 原生执行证据仍待对应平台或 CI 补齐。 ## Decision ### 权限矩阵与有效权限 | 附加根登记策略 | DSH 只读 | DSH 读写(`workspace-write`) | DSH Full-access | | --- | --- | --- | --- | | `inherit` | 只读 | 读写 | Full-access | | `read-only` | 只读 | 只读 | 只读 | | `read-write` | 读写 | 读写 | 读写 | 有效权限 = `resolve(policy)` 按「登记策略 × 会话模式」逐调用解析,工具 cap(`toolAccess`)对三档都只收窄:被封顶的调用对任何附加根不得写,包括继承到 Full-access 与固定读写的根。三档不是全序——`inherit` 在只读模式下弱于固定读写、在 Full-access 下强于它——`setAccess` 的「升级须 `available`」门槛与确认文案按方向分别判断:`inherit → read-write` 在 Full-access 会话是收窄、在只读会话是扩权;`read-write → inherit` 相反;`read-only → 任意` 维持「仅 `available` 可离开只读」。主根与未登记区域永远按会话模式判定,不得借「改写全局模式」近似实现。 ### 持久化编码(domain version 保持 1) 单根条目仍是「恰好一个 grant 键」: | 键 | 策略 | 说明 | | --- | --- | --- | | `recordedPath` | `inherit` | 旧编码;旧 `read-write` 记录升级后语义 = 继承,保留原先随会话模式变化的行为,**不得**解释为固定读写(否则升级在只读会话静默扩权) | | `readOnlyRecordedPath` | `read-only` | 原编码不变;B9 下开始约束 Full-access,是预期的行为变化 | | `readWriteRecordedPath` | `read-write` | B9 新增键,表达固定读写 | | 零键 / 多键并存 | `invalid` | 沿用旧规则,禁止默认修复 | 选 `readWriteRecordedPath` 的理由:与 `readOnlyRecordedPath` 完全对偶;不 reinterpret `recordedPath`;domain v1 单布局不引入 `access` 枚举字段(同 ADR-0013 的否决理由:枚举字段在旧代码剥离后会按 `recordedPath` 重建 grant)。**固定读写条目绝不同时写 `recordedPath`**——旧插件剥离未知键后只能看到 `recordedPath` 才算安全;只含新键的条目在旧代码下判 `invalid`(可见但不可用,fail closed)。降级验收按「不静默扩权」而非「守住 B9 只读边界」:旧插件在 Full-access 本就没有 confinement。 `access` 的 wire / 命令值扩展为 `inherit | read-only | read-write`。`add` 不带 `--access` 保持 revive 语义(重登记恢复原策略);全新登记的缺省策略为 `inherit`。宿主与客户端同包升级,`RootsView.access` 携带真值,不做有损映射。 ### 进程与 fs 接缝 全部接缝用 `method-wrapper.ts` 的公开方法包装(描述符保留、可卸载、不传模型参数),版本形状差异归 `src/compat/` 结构探测。每条接缝都以 `multiRootCompat` 为前置。 | 接缝 | 判定 | 动作 | | --- | --- | --- | | `ctx.shell.execute`(`0.1.5/0.1.6` 结构探测补 `start`) | `spec.sandboxPolicy.mode` ≠ Full-access → 直通;Full-access 且当前 scope 无显式只读根 → 直通 | Full-access + 有显式只读根 → ALS 注入 deny 上下文后委托原方法;`ctx.shell` 在 win32 是 pwsh executor,逻辑与方言无关 | | `ctx.ptcRuntime.execute`(`run` 内部委托 `execute`,同包装覆盖) | 同上,policy 来自参数 | 同上 | | `ctx.subprocess.spawn` | ALS 无 deny 上下文 → 直通 | 有 → 调 `ctx.sandbox` 受限 confine 重写 `spec.argv` 后委托原方法;其余 spec 字段与 signal 原样穿透 | | `ctx.subprocess.spawnTerminal` | env 无 `DSH_PTY_SESSION_ID` → 非 agent PTY(面板 controller 等用户终端),任何模式直通 | env 有 `DSH_PTY_SESSION_ID`(每个支持版本的 `dsh-terminal-bash` backend 启动 agent PTY 时都盖)→ `sessions.get(env.DSH_SESSION_ID)` → `sandboxPolicy.resolve({session})` → scope;无 deny → 直通;有 → 受限 confine / clamped prefix 重写 argv;会话不可解析且 `hasActiveDenyRoots()` → fail closed 拒 spawn | | `ctx.fs` write/edit fence | Full-access:目标在显式只读根内 → `FS_SANDBOX_DENIED`;read-only:目标在固定读写根内且工具未封顶 → 放行 | 其余沿用 workspace-write 判定;零受限根保持快速路径,不引入额外 realpath IO | `ctx.sandbox`(已替换行)增加受限 confine 内部路径,供 `spawn` / `spawnTerminal` 包装调用:`ConfinedSandboxMode` 之外的 mode 由 compat 层一次受控 cast。三种构造: | 会话模式 | 构造 | | --- | --- | | `workspace-write` | 上游 `confine` 结果照旧 `widenProfileArgs` 拼接有效可写根(`inherit` + `read-write`,cap 已过滤) | | `read-only` | 上游 read-only profile 之上 append 固定读写根:Seatbelt 追加 `(allow file-write* (subpath W))`、bwrap 追加 `--bind W W`、Landlock 追加 `--rw W`、Windows ACL 把该根并入 `--write-sid` root 集 | | `danger-full-access` | 自建 restrict profile:Seatbelt `(allow default)` + 每受限根 `(deny file-write* (subpath R))`(last-match-wins);bwrap `--bind / / --dev /dev --die-with-parent` + 每受限根 `--ro-bind R R`;Landlock 与 Windows ACL 表达不出(见下表),受限 confine 抛 `SandboxUnavailableError` → spawn 前拒绝 | **显式只读优先于覆盖同一路径的其他写授权**(尤其平台 temp 与 `/tmp`):构造 allow 集时先做 canonical 前缀剔除。deny 集与 allow 集只取 `available` 成员;`withheld` / `missing` / `redirected` 根不进入任何集合(撤销不重新授权,成员投影也不因 withheld 而把限权根漏出 deny 集——它本来就被 withheld,不授权也不拒绝)。 受限 confine 下,包装在 `result` 层按返回的 `denialSignatures` / `runnerFailureRules` 补 stamp `result.sandbox.{mode, denied, enforcement}`;`proc.sandbox` 事实戳记依赖上游私有 `processFacts`,不可复现,作为如实差异记录(jobs/持久化侧的「曾被拒」标记在该组合下缺席)。 ### 后端能力与失败方式(Phase 1 结论) | 后端 | Full-access + 局部只读 | read-only + 固定读写 | 状态 | | --- | --- | --- | --- | | Seatbelt | 可表达(allow-default + subpath deny) | 可表达(RO profile + subpath allow) | **macOS 原生验证通过**(2026-10-14,`sandbox-exec` CLI:deny subpath 内写 EPERM、ambient 写放行;graft allow 内写放行、其余写 EPERM——deny 必须拼 canonical 路径,`/tmp` 词法拼写不匹配 `/private/tmp` 实际) | | bwrap | 可表达(bind `/` + ro-bind 受限根);`runnerCommand` 自定义 runner 按上游契约同形 | 可表达 | 结构确认 | | Landlock | **已按定案显式拒绝**:「尝试扩展启动器拼写」的结论为不可行——上游启动器仅 `--ro`/`--rw` allowlist union,Landlock ABI 本身无可移植的子树减法;`restrictProfileArgs` 抛 `SandboxUnavailableError`,spawn 前拒绝,不计为该组合已实现 | 可表达(`--ro` 默认 + `--rw` 固定读写根,已实现) | 结构确认 | | Windows ACL | **已按定案延后并显式拒绝**:上游 runner 只导出 grant 模型(`AclSandbox`/`AclWriteGrant`/`workspaceWriteSid`),无 deny ACE 或受限 token 原语;新机制(deny ACE / 受限 token 变体)超出本轮范围。`restrictProfileArgs` 抛 `SandboxUnavailableError`,spawn 前拒绝 | 可表达(独立 runner `--mode read-only` + `--additional-root`,已实现:workspace-write 令牌仅授权固定根集,主根不可写) | 结构确认 | | PTC(run_code) | 纳入 B9:与 shell 同一 ALS 接缝覆盖 | 纳入 | 由 `ctx.subprocess.spawn` 包装统一约束 | 未知形状、不可用 runner、表达不出的权限组合一律在 spawn 前显式拒绝,绝不无约束启动;该拒绝是防越权失败处理,不算对应组合已实现。 ### 生命周期与边界 - `writableRoots` / deny 集只含 `available` 成员;撤销、missing、redirected、withheld 不重新授权目标。 - `registry-contended`(以及 storage-failed / config-invalid / 拆除后)维持空 scope + 拒一切 mutation(含固定读写),不缓存「上次已知」;空 scope 同时标记 `authority: 'unavailable'`,使 Full-access 在该进程按 workspace-write 执行而非信任空 deny 集(见下方审查返工修订)。 - 模式与根变更只影响后续 fs 调用与新启动进程;已运行进程保留启动时内核快照(同 ADR-0012)。PTY 侧上游已有「打开终端时禁止切模式」fence,语义一致。 - 零附加根时全部接缝直通,逐字节保持上游行为。 - `setAdditionalRoots()` 仍是唯一写端口;mutation 读-分类-验证-持久化仍在原序列化队列与 lease/teardown 顺序内。 ## Alternatives Considered | 方案 | 否决理由 | | --- | --- | | 替换 `bash-sandbox` / `pwsh-sandbox` 行挂载 executor 子类 | `executeArgv` 是 protected、`processFacts` 是私有,子类无法复现事实戳记只能整段重实现;行替换还要镜像平台 disabled 与 config 漂移。method-wrapper 与 ADR-0011 的公共接缝先例一致 | | 包装 `ctx.terminals` 或其 backend | `optional-persistent-terminals` 组 `isolate: terminals`,组外拿不到组内服务实例——`dsh-web-app` 上 registry 不可达,只有 `dsh-sdk-minimal` 把它挂在根;backend 委托的共享 `ctx.subprocess.spawnTerminal` + env 判别才是全 profile 可达接缝 | | `subprocess.spawn` 按 argv 形状识别受限调用 | 无法区分「已 confine 的 argv」与裸 argv,二次嵌套 confinement 可能直接失败(本环境实测 `sandbox_apply` 嵌套拒绝);必须靠 ALS 精确传递授权意图 | | PTC 子进程按 env 标记识别 | PTC 把 `process.env` 全键置 `undefined` 后仅显式注入 `DSH_PTC_RUNTIME_NODE`,没有会话标记;且 `execute(spec, policy, …)` 的 policy 本就是显式参数,ALS 更精确 | | 把 Full-access + 局部只读近似为 `workspace-write` + 扩展根 | 改变全局模式语义(主根与未登记区域失去 Full-access),计划明确禁止 | | 固定读写复用 `recordedPath` 或新增 `access` 枚举字段 | 前者升级静默扩权;后者旧代码剥离后按 `recordedPath` 重建 grant(同 ADR-0013 否决) | | 面板 wire 把 `inherit` 映射回 `read-write` 兼容旧客户端 | 宿主与客户端同包发布、版本一致;有损映射只会让显示与执行不符 | | `ctx.shell` 包装改调 `executeArgv` 注入受限 argv | `executeArgv` 是 TS `protected`、argv 构造(bash `['bash','-c',command]` / pwsh `this.argv(spec)`)属 executor 内部形状;ALS + `subprocess.spawn` 只接触公开面 | ## Consequences - 权限解析仍是 `resolve(policy)` 单点,新增「有效权限」投影与 deny 集投影;成员资格投影不受影响(显式只读根仍是完整 workspace 成员:可读、可路由、可注入指令)。 - 新增一个 additive 行(进程消费面包装)与 `ctx.sandbox` 的受限 confine 路径;不替换新上游行,但 `multi-root-providers` 门禁语义扩展到「`ctx.shell` / `ctx.ptcRuntime` 存在时其方法形状符合支持合同」。 - `danger-full-access` 与 `read-only` 不再逐字节保持上游语义——这是 B9 的合同变化:仅在「存在显式只读根」或「存在固定读写根」时行为不同,其余逐字节一致。 - enforcement 元数据差异如实记录:受限 confine 下 `result.sandbox` 由包装层补 stamp;`proc.sandbox`(jobs 事实)缺席该组合。 - Windows enforcement 维持上游 `partial`;Full-access + 局部只读组合在新机制落地前为显式拒绝 + 文档标注。 - ADR-0013 的两档模型、持久化编码与决策表按修订节更新;ADR-0001 的「confinement 全部委托 `ctx.sandbox`」假设、ADR-0003 的门控条款、ADR-0011 的 Windows 单 SID 模型、ADR-0012 的 read-only 授权行同步修订。 ## 修订:Windows Full-access 局部只读重新纳入 B9(2026-10-13,Amendment) 用户撤回本 ADR 中 Windows 新机制的延后范围。本节取代上表 Windows 行的「延后并显式拒绝」决定,历史 Phase 1 结论保留。 Full-access 的 Windows profile 改为选择插件独立 runner 的 `--mode danger-full-access --deny-root`;令牌、deny ACE、owner / parent / token 防绕过与生命周期按 [ADR-0019](./ADR-0019-windows-token-attributes-and-conditional-acls.md)。权限仍从同一 scope 的 `deniedRoots` 读取,Windows enforcement 仍为 `partial`,缺失能力或路径不满足原生前置条件时拒绝启动。Landlock 的决定不变。原生证据与验收状态只维护于路线账本。 ## 失效登记的负授权修订(2026-10-13) 本节取代前文「deny 集只含 available」的决定。Registry 经同一 `setAdditionalRoots()` 写端口发布 available、missing 与 redirected 的有效登记,后两者带 `withheld`,不授予成员资格或写权限。`deniedRoots` 独立按登记策略、工具 cap 与 **recorded canonical 路径**生成;目录消失、刷新或重启不能解除仍登记的只读边界。仅显式移除登记或经校验修改策略才撤回对应负授权。Invalid 记录仍不发布;重新解析不把拒写边界或写授权转移到 redirect 的新目标。fs 直接检查负授权路径,不依赖成员投影。 **Invalid 记录的负授权同样不发布(PR #12 评审订正)**:一条连 grant 字段都解析不出的记录,其 deny 声明本身也不可信——只发布「能确认的部分」等于发明一条记录里没有的边界。因此 invalid 记录不携带任何投影:不授予、也不拒写;Full-access 下其曾登记的路径恢复 ambient 可写。这是有意的取舍而非疏漏:能写坏存储的攻击者本就有 ambient 权限(store 在 `$DSH_HOME` 内,受限 Full-access 下本就受 deny 保护),而损坏面由 `list` / 面板的 `invalid` 状态显著报告。若未来要保留部分可解析记录的 deny 边界,应以单独字段实现,不能借多 grant 字段歧义记录重建语义。 缺失路径的内核启动若不能表达该负授权必须失败关闭,不能因无法挂载路径而裸启动。既有进程仍保留启动快照。 ## 面板与模型说明修订(2026-10-13) 面板响应的 `mode` / `effectiveAccess` 由活会话在宿主派生,只作展示,客户端不能上传授权事实;兼容旧响应缺字段时不猜测有效权限。每根并列显示登记策略与当前有效权限,工具 cap 单列说明。继承与固定读写之间两向切换都在某个模式下扩权,因此均要求确认。模型拓扑在三个模式下均列出配置的工具 cap。 ## 进程诊断与 Linux 后端修订(2026-10-13) Full-access 包装保留受限 prefix 对应的 `enforcement`、`denialSignatures` 与 `runnerFailureRules`,在 shell execution 的公开 `result()` / PTC 返回结果上补事实;execution 身份、调用上下文、缓存和同步/异步形状不变。runner 初始化失败优先于 kernel denial,不把信号退出或成功恢复的命令标为拒绝。`proc.sandbox` 的私有 jobs 事实仍是上游限制,不伪造未观测的进程事实。 PTC 接缝改为公开 `ctx.ptcRuntime.run()`,不包装实现类的私有 `execute`。旧版 shell 的公开 `run` / `start` 同样包装(前台 run 不经过 start/execute),普通 shell、PTY 与 PTC 仍从同一 scope 取策略;真实组合冒烟及安装副本同时验证 Full-access 主根/ambient/固定读写允许、只读拒写和结果事实。 Landlock 的 allowlist 无法实现 Full-access 子树减法,该组合不再直接判 unsupported,而是选 bubblewrap mount 后端:主机根原样读写、只读根单独 ro-bind。不带 workspace-write 的 PID、proc、device 或 tmpfs 替换,保持其他区域的原模式。该路径需要宿主安装且可执行 bubblewrap;不可用按 runnerFailure 报告,绝不裸启动。选择新后端时结果诊断同时切为 bubblewrap 的实际方言,不能保留 Landlock EACCES 拼写。bwrap 的 workspace-write temp mask 下注册的只读树也以 ro-bind 显式恢复读取。 **如实记录**:该回退不在 profile 层预探测 bubblewrap 是否安装——`restrictedExecution` 只借 `confine` 的 read-only 骨架变换 argv,bwrap 缺失要到子进程 spawn 时才以 ENOENT / runnerFailure 形态出现。行为是 fail closed(绝不裸启动),但用户看到的报错是「进程启动失败」而非「后端不可用」;诊断质量限制已知,不混淆为权限拒绝。 ## 外部审查返工修订(2026-10-14,PR #12 评审) 评审复现了两个 Full-access P1 绕过;修复设计经用户批准,另有一个本批一并修复的 Linux 可用性问题。 ### 权限未知不等于没有限制(P1) **复现**:两个 stack 共用同一 store,第二个 `registry-contended`、发布空 scope,deny 集为空——Full-access 的 fs fence 与进程 fence 都把它读作「没有限制」,可以覆写另一进程登记的 `read-only` 根。**决定**:scope 新增 `authority: 'available' | 'unavailable'` 事实,注册表在构造(覆盖 config-invalid 与 acquire 前)、`enterContended` / `enterStorageFailed`、以及 `releaseAuthority` 转场开头标记 `unavailable`,`openDomain` 发布完全部存储记录后才标记 `available`。该事实不可用时: - fs fence 把 `danger-full-access` 降为上游 `workspace-write` 语义——可写集 = `writableRoots({...policy, mode:'workspace-write'})`(主根 + 平台 temp),不并入 scope 的 writable 成员; - 进程 fence 不再走 restricted profile,而是用 `clampedExecution`(以 `['true']` 探一次 `workspace-write` 的 `super.confine`、保留其 profile 前缀与 enforcement / denialSignatures / runnerFailureRules)把 shell / PTC / PTY 的 ambient 执行钳进上游 workspace-write confine; - 模型拓扑在 Full-access + authority 不可用时固定陈述降级句(`REGISTRY_UNAVAILABLE_NOTE`),其他模式与零根仍不贡献; - `workspace-write` 与 `read-only` 模式不变:那里 scope 本就为空,不追加授予。 **取舍与残余**:降级是有意的近似——「主根 + temp」是上游本身可证可授的最大安全集,比「拒绝一切执行」可用、比「放行 ambient」安全。残余已记录:该进程内 Full-access 进程写不了 `read-only` 根(好),但也写不了其它进程合法登记的 `inherit` / `read-write` 根(收窄,接管后恢复);模型与面板看到的是陈述而非新模式名。 ### Full-access 不得沿用旧 FsTarget(P1) **复现**:先 resolve 一个普通目录内的文件,再将其父目录替换为指向 `read-only` 根子目录的符号链接,`writeText(oldTarget)` 用启动前的 targetKey 通过 deny 检查并覆写被保护文件(workspace-write 本来就重解析,所以只有 Full-access 有洞)。**决定**:`checkedTarget` 只在「`danger-full-access` 且 deny 集为空」时返回调用方 target(逐字节保持上游零根行为);其余情形一律 `resolve(target.displayPath)` 取新 target、以 `fresh.targetKey` 跑 deny 检查,并把 `fresh` 交给实际 mutation——检查的身份与写入的身份始终同一个,Full-access 与受限模式一致。 ### bwrap 对缺失 deny 根改用 `--ro-bind-try`(已由后续 P1 修订取代) `--ro-bind ` 会让 bwrap 启动即失败("Can't find source path"),于是一个被删除的 `read-only` 根会打断 Linux 上**所有** Full-access shell(以及它位于 temp 下时的 workspace-write)。**决定**:`denyProfileArgs` 与 `restrictProfileArgs`(含 landlock→bwrap 分支)对 deny 根一律使用 `--ro-bind-try`:源缺失时跳过该 bind 而不是让整个命名空间启动失败。`--ro-bind / /` 的方言识别与 grant bind(`--bind` / `--ro` / 各类 `--rw`)不动。**残余**:Full-access 进程可以在 bwrap 命名空间里重建被删目录并写入——fs fence 仍按 `recordedPath` 拒写,该残余是可接受的进程面缺口,与「missing 根不解除只读边界」的登记语义并存。 ### cordis.patch.yml 注释订正 进程 fence 行注释此前写 `ctx.ptcRuntime.execute`;代码包装的是公开 `run`(私有 `execute` 不作为跨包接缝)。注释已改为 `ctx.ptcRuntime.run`,并补记未知 authority 的 Full-access 降级。 回归:`tests/b9-review-regressions.spec.ts`(反转的两条 P1 复现)、scope / registry-lease 的 authority 状态断言、process-fence 的 clamped prefix 与 unresolvable-terminal 断言、dialects 的 `--ro-bind-try` 期望、prompt-context 的降级句。 ## 缺失 deny 根必须失败关闭(2026-10-14,PR #12 最新 P1) 本节取代上述 `--ro-bind-try` 决定。fs fence 只约束进程内文件工具,不能兜底 shell、PTC 或 Agent PTY 的写入;把缺失源跳过会让可写的父挂载暴露同一路径,命令可重建目录并写入,因此该残余违背「missing 根不解除只读边界」的不变量。 `denyProfileArgs` 与 `restrictProfileArgs`(包括 Landlock 转 bubblewrap)对拒写根统一使用强制 `--ro-bind R R`。路径在登记后或 profile 构造后消失时,由 bubblewrap 在执行目标命令前拒绝启动;不做先 stat 再可选挂载,不放行裸命令,也不授予父目录额外权限。目录恢复后同一 profile 可再次启动,ambient 写入与该根拒写的边界保持原状。代价是缺失根会阻断依赖该拒写挂载的新进程;用户可恢复目录,或显式移除登记。 回归:`tests/missing-deny-root.spec.ts` 覆盖 Full-access bwrap、Landlock fallback 与 workspace-write 可写父挂载三种构造,固定强制挂载拼写;Linux 原生用例在构造 profile 后删除根,验证启动失败、命令未执行、目录未重建,并验证恢复目录后的 ambient 可写与只读根拒写。原生执行前先探测 bubblewrap,沿用已有 kernel-runner 必须执行/显式跳过规则。现有 process-fence 测试同步强制挂载期望。 ## 用户终端直通与 `$DSH_HOME` 负授权修订(2026-10-14) 评审返工的两项用户产品决策,覆盖本节之前 `spawnTerminal` 的 env 标记机制与 deny 集构成。 ### 面板用户终端任何模式都不受本插件约束 **问题**:原机制用 `env.DSH_SESSION_ID` 恢复会话,但上游 `dsh-api-terminal-controller` 给面板终端 spawn 时同样盖 `DSH_SESSION_ID: agent.id`——按该标记识别会把**用户**终端一并 confine。决定按用户定案:沙箱保护的是 agent 行为,面板终端是用户行为,本插件任何模式都不介入。 **机制**:agent PTY 与用户终端的结构差别在 env——全部 11 个受支持版本的 `dsh-terminal-bash` backend 启动 agent PTY 时经 `childEnvironment` 盖 `DSH_PTY_SESSION_ID: spec.sessionId`(与 `DSH_SESSION_ID: spec.owner.id` 并列),而面板 controller 只盖 `DSH_SESSION_ID`。`spawnTerminal` 包装据此判别:`DSH_PTY_SESSION_ID` 缺席 → 用户终端,任何模式直通;存在 → 按 `DSH_SESSION_ID` 恢复会话与策略,受限时前缀 argv,会话不可解析且 `hasActiveDenyRoots()` 时 fail closed 拒 spawn。`ctx.terminals` 本身不包装:`optional-persistent-terminals` 组 `isolate: terminals` 使 registry 在 web profile 上不可达,共享 `spawnTerminal` + env 判别同时覆盖隔离与未隔离两种挂载。 **marker 合同升为启动断言(PR #12 评审订正)**:`DSH_PTY_SESSION_ID` 是判别 agent 与用户终端的唯一事实,env 键不是导出符号,单靠版本或导出探测无法确认它仍被盖章——backend 一旦停盖,agent PTY 会被误判为用户终端而在受限 Full-access 下静默直通(fail-open)。因此 `multi-root-compat` 门禁在注册服务前运行 `src/compat/pty-marker.ts` 的内容探针:经 `@deepseek-ai/dsh` 锚点解析已装 backend 的发布产物并核验其中仍写入该 marker。backend 缺席时合同空虚成立(无 backend 即无 agent PTY);backend 在而 marker 不可确认(未写入或源码不可读)时判 `unverifiable`,拒绝整个插件装载而非放行——与 `mixed`/`incomplete` 同级,`DSH_MULTI_ROOT_COMPAT=warn` 不可放宽,升级车道的「装载被拒」本身就是该版本破坏合同的证据。 **已知保守性**(如实记录):`hasActiveDenyRoots()` 是全 store 级判断——任一 primary 存在显式只读根、或 authority 不可用时即返回 true。一个携带死会话标记的 agent PTY 因此在所有 primary 上被拒 spawn,而非仅所属 primary;该路径只覆盖会话已消亡的异常场景,方向是 fail closed,有意不做每主根细粒度判别。 ### `$DSH_HOME` 在受限 Full-access 下加入 deny 集 **决定**:scope 配置新增 `dshHome`(patch 行以 `dshHomePath()` 注入;非绝对路径日志报错并忽略)。`resolve()` 在 `danger-full-access` 且(registry/工具 cap 派生的 deny 集非空 **或** authority `unavailable`)时把 canonical home 追加进 `deniedRoots` 并以 `protectedHome` 暴露;`workspace-write` / `read-only` 与零根健康 authority 的 Full-access 都不追加(保持零根直通)。主根位于 home 内时 deny 胜出(Full-access 下主根不可写),不做剔除。fs fence 对 home 命中给出专用拒写文案;进程 fence 的 restricted / clamped prefix 自动携带该 deny——`clampedExecution` 改为 `(policy, deniedRoots, signal)`,按 workspace-write 分支同一规则把 deny 集 graft 到探针 profile 上(POSIX 用 `denyProfileArgs`,Windows 用 `widenWindowsProfile(…, [], denied, primary)`),`DialectUnrecognizedError` → `SandboxUnavailableError` fail closed(含 Landlock 在主根为 `~` 等不可表达情形)。模型拓扑在 `protectedHome` 存在时固定陈述「`$DSH_HOME` 目录下写入被拒」并打印绝对路径。 **Windows 风险如实记录**:Windows 路径的 deny 经 `runWindowsDenied` 对 deny 根整树递归写入**持久化** deny ACE(含父目录 `FILE_DELETE_CHILD` / `WRITE_DAC` 防移除守卫),拆卸时**不撤销**——ACE 只匹配 broker 为该根派发的 deny-only 组 SID,ambient 进程不受影响,但 `$DSH_HOME` 全部节点 ACL 被长期改写、每次受限 spawn 都要整树 `readdir`+`SetSecurityInfo`;任一节点不可写/不可改 DACL 时整个 spawn 在启动前 fail closed。`$DSH_HOME` 内含 storages/、lease 文件、profiles 与宿主自身工件——宿主进程不在受限令牌内不受影响,但该持久化 ACL 写入量与节点失败面是本轮已知的平台代价,未另造机制。 回归:process-fence 的 agent/user 终端分轨断言(controller 形状直通、backend 形状受限/钳制、不可解析会话 fail closed)、scope 的 `protectedHome` 全触发矩阵、b9-review-regressions 的 home 拒写(含主根位于 home 内)、patch 行 `dshHome`、prompt-context 的 home 陈述句。 ## 临时区与反向包含修订(2026-10-15) 本节取代前述未知 authority 的「上游 workspace-write(主根 + temp)」降级。共享临时区允许合法登记只读根,读不到登记表的进程不能证明这些 grant 安全。authority 不可用期间,**Full-access 与 workspace-write 均只授予主根写权限**,不授予共享临时区;read-only 不变。fs 从同次 scope 取主根;sandbox 从上游 read-only 骨架仅 graft 主根,保留同步/异步返回形状与实际后端诊断。Full-access 的 shell / PTC / Agent PTY 仍经同一 clamped prefix。主根本身位于临时区时,仅其子树可写,临时区兄弟目录仍不可写。接管后正常 grant / deny 恢复,用户终端仍直通。 sandbox 在 confine 入口捕获一次 scope,避免异步 profile 探测后混用另一份权威快照;新进程沿用启动时权限快照的既有语义。模型在 Full-access 与 workspace-write 下均陈述仅主根可写且共享临时区不可写;权威未知时不呈现旧成员的 grant。 拒写与授予的相交判断改为**双向包含**。不仅 deny 在 grant 下,grant 在 deny 下也必须限权:例如 home 包含主根,或已登记的只读根包含平台临时区。Seatbelt 追加祖先 subpath deny,bubblewrap 在 grant 后强制 ro-bind deny 根;Landlock 与 Windows allow-token profile 无法减法,两个方向的重叠都拒绝启动。Windows read-only 骨架 graft 主根时同样检查该重叠,不能因原 mode 为 read-only 而漏判。缺失 deny 源仍失败关闭,不改用可选挂载。 回归见 `tests/authority-boundaries.spec.ts`:双真实 registry stack + 临时区只读根的 write/edit、两种可写模式、接管与显式移除、四后端主根授权、双向相交和 Seatbelt/bwrap 原生执行;Windows 原生主根授权与容量回归在 `tests/windows-native.e2e.ts`。执行证据统一记路线账本。 ## 祖先目录改名不得解除拒写(2026-10-15,PR #12 评审 P1) **复现(macOS 原生 sandbox-exec 已验证)**:受限 Full-access profile `(version 1) (allow default) (deny file-write* (subpath "$T/parent/root"))` 下,直接写 `root/f` 被拒;但 `mv "$T/parent" "$T/parent2" && echo hacked > "$T/parent2/root/f" && mv "$T/parent2" "$T/parent"` 成功改写只读根。Seatbelt 只检查被操作路径本身,祖先目录在 subpath 之外;改名成功后 fs fence 的 deny 集仍记录旧 `recordedPath`,进程内 fence 同样放行——所以必须由内核在改名这一步就拒绝。bwrap 同理:`rename` 挂接点本身返回 EBUSY,但挂接点的祖先仍可被改名,被删路径由后续 profile 的 `--ro-bind` 落到重建的假目录上。 **决定**:deny 边界必须钉住拒绝根的每个祖先目录,使祖先改名(而非只改子树)不可能发生。 - **Seatbelt**:deny 形式从 `(deny file-write* (subpath R)…)` 扩为 `(deny file-write* (subpath R)… (literal A)…)`,A 为被拒根的每个真祖先目录(不含文件系统根),`restrictProfileArgs`(Full-access)与 `denyProfileArgs`(workspace-write 的 covered 根)一致。literal 只拒绝对祖先目录本身的 file-write*(改名、chmod、touch、unlink),其子孙照常按 grant 判定——`touch $T/parent/sibling` 已实测放行。 - **bwrap**:把每个祖先做成命名空间内的挂接点(`--bind A A` 自绑),`rename(祖先)` 返回 EBUSY。`restrictProfileArgs`(含 Landlock→bwrap 回退)在 `--bind / /` 之后、全部 `--ro-bind R R` 之前按浅→深顺序放置;`denyProfileArgs`(workspace-write)只对严格位于可写 **bind** 挂载目标内的祖先自绑——`--tmpfs` 目标内的祖先不自绑(tmpfs 内的改名只搬动命名空间私有挂载、不伤宿主,而自绑反而会暴露并使可写 tmpfs 所遮蔽的宿主内容),可写目标之外(含挂载点自身)的祖先本就不可改名。ro-bind 仍保持最后,避免任何自绑遮蔽 deny。 - **Windows 不受影响(已被取代)**:deny ACE 写在目录对象上,`rootDenySid` 按路径确定,父目录改名不影响该对象上的 ACE——当时判断边界随目录走,不需要祖先钉扎。**本条已被 [ADR-0020](./ADR-0020-windows-normal-deny-groups.md) 的 Phase B 修订(2026-10-16)取代**:ACE 确随对象走,但改名把受保护子树搬到 recorded deny 集不覆盖的新路径——宿主 fs fence 只匹配登记拼写,新路径下的对象不再被其覆盖(shell→宿主新路径缺口,R1)——故 Windows 祖先钉扎实为必需并已由 ambient deny runner 实施。 - **Landlock→bwrap 回退**同享同一组 `--bind A A`。 **代价**:受限 Full-access 下进程对拒绝根(含 `$DSH_HOME`)的任一祖先目录本身失去写权限——不能 rename / chmod / touch 这些目录,例如 `$DSH_HOME` 为 `~/.dsh` 时受限进程不能改名家目录本身;祖先目录内的读写不受影响。该收窄是刻意的:改不掉祖先就搬不走拒写边界。Linux 侧假设(祖先自绑使 rename 失败)由 `tests/authority-boundaries.spec.ts` 的 bwrap 原生用例在 CI 上验证,本机无 Linux 环境不作原生断言。 回归:`tests/dialects.spec.ts` 的祖先字面量/自绑拼写与排序、`tests/process-fence.spec.ts` 的 deny 形式断言、`tests/authority-boundaries.spec.ts` 的 Seatbelt 原生改名拒绝(Full-access 与 workspace-write covered 根两路)——原生用例同测「祖先被改名失败 + 同父目录兄弟文件照常可写」。 ## 负授权保留与顺序无关冲突(2026-10-16,分支安全审查 Phase A) 分支安全审查([2026-10-16 分支安全审查与硬链接隔离修复计划](../plans/active/2026-10-16-branch-security-review-and-hardlink-isolation.md))在 `feat/sandbox-permision-harden` 上证实 R2、R3 两条 P1 真实可达,本节记录其定案修复;R1(Windows 祖先改名跨工具绕过)与 R4(硬链接 ACL 副作用)按该计划的 B/C 阶段另行实施,实施状态以路线账本为准。 ### read-only 的 deny 在 graft 之后应用(R2) `widenForScope` 原先只在 workspace-write 分支调用 `denyProfileArgs`;read-only 分支 graft 完固定读写根即结束。一个无需手改存储即可构造的状态——子根登记只读后删除目录,再把仍存在的父根登记为固定读写——会让三个 POSIX 后端的 read-only profile 同时漏掉拒写边界:进程面可写、fs 面拒写。现在两种受限模式都先扩宽/嫁接、再对同一 deny 集应用 `denyProfileArgs`:Seatbelt 在 allow 之后追加 deny(last match wins),bwrap 沿用缺失根失败关闭的决定使用强制 `--ro-bind`(缺失源启动即失败),Landlock 无法表达「rw 祖先内的只读子树」仍抛 `DialectUnrecognizedError` 并转成 `SANDBOX_UNAVAILABLE`;windows-acl 此前已在 `widenWindowsProfile` 拒绝同一重叠。同步/Promise 返回形状与 `enforcement` / `denialSignatures` / `runnerFailureRules` 不变。 ### 成员冲突:顺序无关、负授权保留(R3) `classifyStoredRoots` 改为两遍: - **第一遍逐条独立判定**:结构信任(id、唯一 grant 字段、recorded boundary 绝对、非网络/设备路径),并以 **recorded boundary** 判定主根重叠——先于 `path` 的 redirect 解析,不让攻击者可控的当前目标参与授权比较;之后才解析当前路径判 `redirected` / `missing` / `available`。 - **第二遍对全部结构有效记录的 recorded boundary 两两比较**(含 missing 与 redirected):任一方向包含即双方判 `invalid`、`invalidReason: 'membership-conflict'`(内部字段,不持久化不上线)。冲突集合从第一遍快照构造,与迭代顺序无关;展示排序、`move`、alias、refresh 与重启都不能把一条明确的只读登记变成「没有边界」。 发布规则相应拆分:结构 invalid 的记录照旧不发布任何投影(连 grant 都解析不出的记录其边界声明不可信——前文 PR #12 订正的取舍不变);成员冲突的记录以 withheld、仅负授权形式发布——成员资格与写授权都拿不到,但 recorded boundary 仍进入 `deniedRoots`。修复路径是操作者显式移除其中一条登记,在此之前拒绝放行两侧授予。 新增与复活的冲突校验和分类共用同一份结果:`registry.add` 对每条既有状态调用 `reservedRootBoundary`(结构有效记录的单一 recorded grant,含 missing、redirected 与 membership-conflict),并**按索引**精确豁免本次复活的同一行——按行而非按路径,同路径的其他记录仍保留其边界。该 helper 只从分类结果推导,不引入平行授权表;scope 的 deny 集仍只来自 `setAdditionalRoots` 单一写口。本节取代 ADR-0004 第 10 条「redirected 先于重叠判定」的顺序描述与「重叠者全部不授予且不保留 deny」的隐含结果,历史条目保留。 回归:`tests/roots.spec.ts`(reservedRootBoundary 各状态、三方重叠不依赖顺序、recorded boundary 先于 redirect 判主根重叠、损坏记录不进冲突遍);`tests/registry.spec.ts`(missing/redirected 子根的两个包含方向拒绝新增、redirected 保留原边界、legacy 重叠两种展示顺序、move/alias/refresh/重启后 deny 不变、冲突根 withheld 且三模式 deny 保留);`tests/sandbox-multi-root.spec.ts`(mock 上游 `confine` 的四方言 read-only 组合,sync/Promise 两形状:Seatbelt deny 在 allow 之后、bwrap 强制 `--ro-bind`、Landlock 与 windows-acl fail closed、无 graft 时逐字节直通);`tests/b9-review-regressions.spec.ts`(重建缺失只读子根后,真实 `MultiRootFileSystem` 的 writeText/edit 仍拒绝、内容不变)。 ## Related Documents - [B9 专项计划](../plans/completed/2026-10-13-b9-root-access-and-sandbox-modes.md) - [路线账本 · B9 条目](../plans/active/2026-09-12-multi-root-workspace.md#b9附加根三态权限与跨-sandboxmode-执行一致性2026-10-13) - [ADR-0013:Per-Root 访问级别与 Per-Tool 封顶](./ADR-0013-per-root-and-per-tool-access.md)(本 ADR 修订其权限模型与编码) - [ADR-0001:Provider 替换范围](./ADR-0001-provider-replacement-scope.md) - [ADR-0003:方言 grant 拼接](./ADR-0003-dialect-grant-widening.md) - [ADR-0011:多根消费方](./ADR-0011-multi-root-workspace-consumers.md) - [ADR-0012:Session origin 与进程快照](./ADR-0012-session-origin-and-effective-cwd.md) - [ADR-0009:DSH 兼容合同](./ADR-0009-dsh-compat-contract.md)