gygygfg/dsh-winstage-sandbox

dsh-winstage-sandbox

DSH plugin: Windows staging-candidate-selective-commit sandbox (files + registry) with a review panel

未分類agentdeepseek-harnessdsh

WinStageSandbox — Windows 暂存—候选—选择性提交沙箱

按《Agent 沙箱暂存—提交架构设计手册 v3.0》实现,面向 Windows + DeepSeek Harness。

核心思路:把"隔离"和"暂存"合并成同一件事——沙箱唯一可写根是暂存树, 真实工作区始终不被写入;沙箱内产生的变化冻结为候选,用户批准后才逐文件提交。


安装(DSH 插件)

# npm(已发布时)
dsh plugin --profile web add dsh-winstage-sandbox
# 或直接从 GitHub 安装(无需 npm)
dsh plugin --profile web add github:gygygfg/dsh-winstage-sandbox
  • 前置:Windows 10/11;启动时会做环境监测(非 Windows 或本会话无法建立受限令牌 ⇒ 拒绝启动,逃生口 WINSTAGE_SKIP_ENV_GATE=1)。
  • 开关:设置页可开关。关闭时文件面立即退回平台 SandboxedFileSystem,命令面在装配期 交还平台 pwsh-sandbox(运行中切换需重启宿主)。详见 docs/插件启动-环境监测与开关保证.md。
  • shim:发行包只带 C 源码;需要透明模式先在本机 node tools/fetch-toolchain.mjs 再 node tools/build-shim.mjs。
  • 发布/收录说明见 PUBLISHING.md。

1. 快速开始

cd C:\Users\Administrator\Desktop\dsh-winstage-sandbox

# ★ 一键跑完全部测试(推荐从这里开始)
.\autotest.cmd                    # 全部套件 + 生成 JSON 报告
.\autotest.cmd --skip-audit       # 只跑离线确定性测试(不需要未受限会话)
.\autotest.cmd --verbose          # 失败时打印完整输出

# 其他入口
.\verify.cmd                      # 25 套件离线测试的轻量版(无报告;清单 = OFFLINE_SUITES)
.\diag.cmd                        # 绑定表契约 + 完整 init(需未受限会话)

1.1 自动测试运行器 autotest.cmd / autotest.mjs

一次运行 25 个离线套件 + 4 个沙箱套件 + 1 段沙箱审计,分三段,并写出机器可读报告:

段套件需要未受限会话说明
A25 组离线确定性测试(OFFLINE_SUITES,与 verify.cmd 逐字一致)否手册不变量、端到端链路、结构体布局、命令解析、执行器编排、审计解析、元测试、注册表守卫、工作区回归、AppContainer 运行期、probe 自杀守卫,第三轮新增的 网络策略 / 进程缓解 / 资源上限、补登记的 WFP 布局、两个治理套件(接线治理 / 残余基线),C3 新增的 审批策略一致性守门(policy-never-consistency),「补丁工具包 + 源码封印」轮的 源码基线封印(baseline-integrity)与 DSH 补丁守门(dsh-patch-guard),缺陷③ 轮的 T0 污染 fail-closed(boundary-degraded-failclosed),以及缺陷①b 轮的 白障候选捕获(whiteout-candidate-capture,由 .t\shim-delete\test-whiteout-capture.mjs 提升)
B4 个沙箱套件:diag-bindings、file-cow-dispositions、registry-unstaged-wow64、delete-capture是绑定表契约 + 完整 init();文件 CoW 处置矩阵;注册表 WOW64 视图;缺陷①回归(TS 档四种删除写法必须进暂存 + 白障;由 .t\shim-delete\test-delete-capture.mjs 提升,依赖 shim\out\winstage-shim.dll)
Caudit是沙箱内真实攻击探针(结构化 JSON)

⚠ 清单口径以代码为准:OFFLINE_SUITES 是唯一权威,verify.cmd 的循环列表必须与之逐字一致。 这件事从第三轮起由 tests/suite-wiring.mjs 机器检查(此前两张清单曾静默地顺序漂移)。

退出码:0 无失败(含因机制边界跳过 C 段)|1 存在失败|2 环境错误。

三种总判定:

  • PASS —— 全部通过
  • PASS-OFFLINE-ONLY —— 离线全通过,但 C 段因当前会话受限、无法建立嵌套沙箱而跳过。 这是机制边界不是失败,报告中会在 guaranteesNotProvided 里写明"未提供的保证"。
  • FAIL —— 存在失败套件

报告写到 .t/test-report.json,含每个套件的状态、耗时、断言计数、原始输出路径, 以及按手册第 17 章要求的 guaranteesNotProvided(仍未提供的保证)。

失败时自动打印最后一个失败套件的末尾输出以及 audit 的 fail 明细,便于直接定位。

为什么 autotest 是 Node 而不是 .ps1:本机执行策略为 RemoteSigned 且脚本未签名, .ps1 需要 -ExecutionPolicy Bypass,而 Bypass 子进程在本环境被拒。 用 Node 编写没有执行策略摩擦,也便于复用模块判断"本会话能否建立嵌套沙箱"。

为什么需要 .cmd 包装:受限令牌下 spawnSync 默认的 stdio: 'pipe' 走命名管道, 客户端打开请求需要受限 SID 未被授予的写权限,子进程创建直接 EPERM。 运行器改用文件描述符重定向捕获输出,完全绕开命名管道。 这正是 run.cmd 存在的原因,已作为文档化残余边界 R10 记录。

1.2 元测试:运行器本身必须能检出失败

tests/meta-runner.mjs 用"人为失败的子进程"反证运行器的判定逻辑。 一个永远不会报失败的运行器比没有运行器更糟 —— 它给出虚假的绿色。 (本项目已经踩过一次"假通过"的坑:缺陷 11,见 docs/实测证据记录.md。)

1.3 手动调用

# 初始化暂存区
.\run.cmd src\cli.mjs init    --workspace .\.t\ws

# 在 Windows 受限令牌沙箱内执行命令(写操作落进暂存树)
.\run.cmd src\cli.mjs exec    --workspace .\.t\ws -- powershell -NoProfile -Command "ni new.txt"

# 查看状态 / 待审 / diff
.\run.cmd src\cli.mjs status  --workspace .\.t\ws
.\run.cmd src\cli.mjs review  --workspace .\.t\ws
.\run.cmd src\cli.mjs diff    --workspace .\.t\ws

# 选择性提交(只应用部分路径)
.\run.cmd src\cli.mjs apply   --workspace .\.t\ws --paths new.txt

# 从沙箱内部发起真实攻击探针
.\run.cmd src\cli.mjs audit   --workspace .\.t\ws

1.4 自动测试服务(请求即测试,完成后返回输出)

不想守着终端时,可以起一个常驻服务,收到请求就跑测试并返回结果。

# 启动服务(默认 127.0.0.1:8737,随机令牌会打印在控制台)
.\testservice.cmd
.\testservice.cmd --port 8800 --token mytoken      # 指定端口与令牌

# 用客户端触发(推荐,省得记 curl 语法)
node src\testclient.mjs --url http://127.0.0.1:8737 --token <令牌>
node src\testclient.mjs --token <令牌> --only selftest,audit
node src\testclient.mjs --token <令牌> --async        # 立即返回 jobId
node src\testclient.mjs --token <令牌> --watch <jobId>
node src\testclient.mjs --token <令牌> --log <jobId>

API

方法路径说明
GET/health探活(唯一不需令牌的端点)
GET/suites可用套件清单
POST/run同步跑一轮,完成后返回完整报告
POST/jobs异步启动,立即返回 jobId
GET/jobs列出全部 job
GET/jobs/<id>查询单个 job(状态、进度、报告)
GET/jobs/<id>/log纯文本日志(逐套件结果 + 仍未提供的保证)
DELETE/jobs/<id>取消 job
GET/report/latest最近一次完成的报告
GET/report/latest/<suiteId>某套件的原始结果

请求体(JSON,可选):{ "workspace": ".t/ws", "skipAudit": false, "only": ["selftest"] } 同名参数也可走 query string(?wait=0 等效于异步)。

实测示例

POST /run {}
→ overall = PASS-OFFLINE-ONLY
  套件 8 通过 / 0 失败 / 1 跳过   断言 222 ok / 0 bad   1203 ms
  [PASS                  ] selftest            217 ms  48 ok / 0 bad
  ...
  [SKIPPED-NESTING-LIMIT ] audit               113 ms   2 ok / 0 bad  coverage=13%

安全边界(R13–R16,不是可选项)

这个服务按请求执行本机测试套件,本质上是本机代码执行入口。因此:

编号边界实现
R13只监听回环默认 127.0.0.1,并校验 remoteAddress 属于回环,否则 403
R14强制令牌默认随机生成并在启动时打印;除 /health 外全部要求 Authorization: Bearer <token>
R15套件白名单只接受固定的套件 id,绝不接受任意命令或脚本路径;非法 id 返回 400
R16串行执行显式 FIFO 队列 + 单一泵,同一时刻只跑一轮,避免并发互相污染状态

为什么必须串行:手册第 17.2 节要求套件隔离配置、注册表、cwd、缓存、端口与持久状态。 并发跑两轮测试会让它们争用同一份 .t 工作区,产生"偶发失败"这类最难查的问题。

为什么用 Node 内置 http:零依赖,且与测试同栈 —— 服务与 CLI 直接复用 src/testrunner.mjs 的同一份套件清单与判定逻辑,避免"CLI 说通过、服务说失败"的漂移。

注意:Start-Process 和 start /b 在本沙箱下都无法让服务常驻(进程随父 shell 退出), 请在前台运行 testservice.cmd,或用后台作业方式启动。


2. 架构

┌─────────────────────────────────────────────────────────────────────┐
│ 逻辑视图(对 AI 唯一可见的世界)                                     │
│   read_file / write_file / edit_file / delete_file / list_files      │
│   / search_files            → src/tools.mjs   (进程内层:同一投影)  │
├─────────────────────────────────────────────────────────────────────┤
│ 统一工作区服务 Workspace Service                    → src/workspace.mjs│
│   基线快照 · 逻辑状态(暂存/删除/损坏) · 目录合成 · 路径映射           │
│   候选冻结(before+after) · 选择性应用 · 陈旧基线保护                  │
│                                     ↓ src/store.mjs                  │
│   内容寻址 blob 存储(自动去重,候选两侧不可变)                      │
├─────────────────────────────────────────────────────────────────────┤
│ 受信启动器(Windows 原生隔离)                     → src/executor.mjs │
│   WRITE_RESTRICTED 受限令牌(限制 SID 交集=唯一可写根)               │
│   + Low 完整性标签(no-write-up 兜底)                               │
│   + deny FILE_DELETE_CHILD(关闭父目录删除权旁路)                   │
│   + Job Object KILL_ON_JOB_CLOSE(进程树回收)                       │
│   + 环境变量从允许清单重建(绝不 merge 父环境)                      │
│   + 显式 lpEnvironment(绕开依赖包 lpEnvironment=NULL 的限制)       │
├─────────────────────────────────────────────────────────────────────┤
│ 能力探测 CapabilityProbe                            → src/capability.mjs│
│   真实 Win32 调用实测 · 环境指纹缓存 · 档位选择 · fail-closed         │
├─────────────────────────────────────────────────────────────────────┤
│ 审计(沙箱内攻击探针)                                 → src/audit.mjs │
└─────────────────────────────────────────────────────────────────────┘

文件清单

文件职责
src/paths.mjs路径规范化(链接解析、长路径前缀、盘符大小写)、词法/canonical 双重边界、敏感路径硬拒绝表
src/store.mjs内容寻址 blob、暂存清单、候选队列、清理前置权限修复
src/workspace.mjs统一工作区服务:存在性/读取/枚举/搜索/写入/删除/目录合成/差异/候选/选择性应用
src/tools.mjs工具面:结构化返回(四种"空"可区分)、路径还原、所有路径参数硬校验
src/executor.mjs受限令牌启动器、Job Object、显式环境块、内建自检
src/capability.mjs能力探测、环境指纹、档位选择、可选功能文件标记探测
src/audit.mjs沙箱内攻击探针与证据报告
src/cli.mjs命令行入口
tests/selftest.mjs手册第 17 章硬验收自测(48 项)
tests/e2e-flow.mjs端到端链路验证(捕获→冻结→diff→选择性提交)
docs/Windows功能开启清单.md需要开启哪些 Windows 功能/权限
docs/实测证据记录.md两侧会话的实测证据、缺陷复盘、仍未提供的保证

3. 手册不变量 → 实现位置 → 自测证据

手册条款不变量实现位置自测用例
1.1 / 2.1统一视图:所有工具看同一版本workspace.mjs 全部读取走同一投影统一工作区 9 项
1.1-2连续修改跨 attempt 存活store.mjs 清单常驻「连续两次写同一文件可叠加」
1.1-3删除是持久逻辑状态STATE.DELETED + 副本缺失=损坏删除权威性 6 项
3.1存在性区分文件与目录exists() 返回 kind「存在性判断区分文件与目录」
3.1副本丢失 ≠ 删除,禁止回退真实磁盘verifyProjection() + WORKSPACE_CORRUPT「暂存 blob 丢失时明确报损坏」
3.4连续修改叠加持久 manifest.entries「连续两次写同一文件可叠加」
3.5写文件前准备受控父目录synthesizeParents()「深层新建文件可见」
3.7删除态不被复活读取先查清单,命中即返回「读已删除文件报不存在」
3.8 / 12.1取代限定在同一分支与修订关系内freezeCandidate() 按 session + 路径交集「同一路径的新修订取代旧待审」
3.9暂存路径保留 basename/扩展名staged/<rel>「暂存路径保留 basename 与扩展名」
3.10正反向映射幂等ensureEntry() 复用既有条目「同一路径重复暂存不产生新条目」
3.11目录枚举合并并递归合成父目录listDir() 合并 + hasStagedDescendant「合成目录可枚举出子项」
4.1路径还原覆盖错误分支restorePaths() / restoreDetail()「工具结果只暴露逻辑路径」
4.2退出码 0 且无输出 = 完成无输出completedWithoutOutput见 exec 输出
4.3空/无匹配/不存在/无权限四种结果RESULT 枚举结构化返回语义 5 项
5.1 / 5.5探测与真实执行一致;缓存不替代实例检查probe() + instanceChecks()probe 输出
5.2 / 1.2能力不足拒绝任务,不绑定真实工作区为可写init() 抛 SANDBOX_UNAVAILABLESANDBOX_UNAVAILABLE 路径实测
6 / 7一个 attempt 一条物化路径;删除捕获完整materializeForExecution() + captureAfterExecution()删除型变更应用后真实消失
8.3环境从允许清单重建,禁止 mergebuildChildEnvironment()审计 secret-env-blocked / proxy-env-blocked
12.1无净变化不入队freezeCandidate() 提前返回「无净变化时不入队」
12.2部分应用不丢其余applyCandidate() 保留 remaining选择性应用 4 项
12.3陈旧引用重定向 + 幂等resolveCandidate() 沿链 + 环路保护「陈旧候选可沿 superseded_by 链解析」
12.5丢弃是有状态操作discardCandidate() 先落状态后回收丢弃 3 项
12.6diff 两侧都在候选里冻结blob 内容寻址 + 冻结哈希「候选冻结了 before 与 after 两侧内容」
12.7待审计数按文件summary.files / pendingFilesstatus 输出
13.1清理前先修复权限,只作用已验证归属makeRemovable() 不跟随链接GC 2 项
16.6遮蔽前先规范化,符号链接不能绕过maskOf() 先 canonical 再判豁免「junction 逃逸仍被遮蔽」
16.7硬边界先于可协商项;覆盖所有路径参数assertToolPaths() 先遮蔽后边界「经 junction 读取被硬拒绝」
16.8豁免限定作用域,不含自己工作区内才豁免;.dshstage 永不豁免豁免 2 项

自测结果:48 / 48 通过。 详细报告:.t/selftest-report.json。

自测抓出的前 3 个真实缺陷(安全相关,全部已修)

按手册第 17 章纪律,失败用例暴露的是实现缺陷而非测试问题:

  1. 遮蔽顺序写反(安全相关):maskOf() 先查遮蔽表、后用 isInside() 判豁免, 而 isInside() 内部会 canonical 化 → 工作区内指向宿主敏感目录的 junction 被误判为"在工作区内"而获得豁免。修复:先 canonical 解析,只有解析后仍在 工作区内才谈豁免。(手册 #16.6 / #16.8)
  2. 边界判定混淆词法与 canonical:relative() 用 canonical 判定, 导致 junction 逃逸时直接抛异常,遮蔽表没有机会给出 SANDBOX_PATH_MASKED。 修复:新增 lexicalInside(),边界判定用词法,逃逸交给遮蔽判定。(手册 #16.7)
  3. 存在性查询以异常表达"不可见":exists() 经 relative() 抛 PATH_OUTSIDE_WORKSPACE,而不是返回不可见。修复:存在性用词法判定, 解析后逃逸返回 source: 'masked' 且不暴露解析目标。

其余 13 个缺陷(编号 4–16)见下面 §4 的完整清单。


4. 当前实测状态(诚实声明)

关键结论:同一份代码在受限会话内无法建立沙箱、在未受限会话内可以。 这从两侧实测证明了"Windows 进程隔离不可嵌套"。

★ 验收结论(第 12 轮,未受限 PowerShell)

汇总: {"pass":19,"fail":0,"residual":3,"informational":2,"not-run":0}
覆盖度: 100%  判定: pass-with-residuals
手册第 17.1 节硬验收项实测结果
未授权宿主句柄 / 越界文件效应✅ 写/删越界全部 denied;暂存根内 ok
进程内工具访问敏感路径⚠ 部分收敛(SAM/SYSTEM/会话/SSH/DPAPI/Git 凭据 → 拒绝;hosts/Unattend/npmrc → 可读,属残余)
密钥与代理不进入子进程✅ present=false
临时目录会话私有✅ TEMP 重写为 dsh-stage-temp\<uuid>
生命周期与资源归属✅ Job 总进程数增量 1、子进程退出码 0、纳管确认
清理与自检✅ init() 自检 + dispose() 幂等(离线测试覆盖)

3 个 ⚠ 是如实声明的残余边界 R1(本后端限制写/删但不限制读取),不是失败, 但不得声明为硬边界(手册 #16.10)。

项进程内(DSH GUI 会话)未受限 PowerShell证据
暂存—候选—提交主链路✅ 48/48✅ 48/48[实测]
端到端链路✅ 通过✅ 通过[实测]
ACL / 进程库可加载✅✅[实测]
建立受限令牌 + ACL 授予❌ CreateRestrictedToken 失败✅ 成功(tier=T1,flags=0x2000)[实测] 两侧对比
沙箱内写入/删除边界⏸ 无法取证✅ 全部取得实测结果[实测]
沙箱内读取面⏸ 无法取证⚠ 3 项可读(残余边界 R1)[实测]
沙箱内攻击探针覆盖率⏸ 13%✅ 100%[实测]
网络收敛⚠ 不涉及,残余边界 R2同[官方]

[实测] 隔离不可嵌套:受限会话内令牌缺 TOKEN_ADJUST_DEFAULT / TOKEN_ADJUST_SESSIONID, CreateRestrictedToken 失败;在未受限会话内同一调用成功。 因此沙箱必须由未受限宿主进程建立,在已受限会话内必须 fail-closed。

完整证据与"仍未提供的保证"见 docs/实测证据记录.md §C.1.1 与 §D。

实测暴露的 16 个真实缺陷(已全部修复)

#症状根因修复
1run.cmd 报 'WRITE_RESTRICTED' 不是内部或外部命令批处理含 UTF-8 中文注释,cmd.exe 按 OEM 代码页解析并当成命令执行run.cmd 改为纯 ASCII
2this.api.spawnPipedProcess is not a function我捕获的是 AclSandbox 的私有安全绑定表,它不含进程库原语新增 mergeBindingTables():以低层表为基底,只补入 ACL 独有扩展
3QueryInformationJobObject failed (24 …长度不正确)该 API 要求长度恰好等于结构大小;原实现传 64(实际 48)实测确定 48 并固化
4报 did not expose spawnPipedProcess,且被错误归因为令牌不足混为一谈:loadWin32ProcessBindings() 返回低层表(从不含 spawnPipedProcess),而 spawnPipedProcess(api, options) 是模块级函数三者分离取用;审计归因改造:只有确实因令牌权限不足才标 not-run,其余一律标 fail
5offset is out of range … Received 48我凭记忆写的结构体偏移整体错 4 字节(应为 32/36/40/44,我写成 32/40/44/48)修正偏移;抽出 parseBasicAccounting() + 边界断言;新增不依赖 Win32 的 tests/struct-layout.mjs
6CreateProcessAsUserW failed (Win32 2),命令 pwsh该 API 不做 shell 式可执行文件解析:不补 PATHEXT、不查 App Paths。裸名字必然 ERROR_FILE_NOT_FOUND新增 resolveExecutable(),在宿主侧按 CreateProcess 搜索顺序解析成绝对路径
7修好解析后仍 Win32 2本机没有安装 pwsh,只有 Windows PowerShell 5.1;代码硬编码了 pwsh新增 resolvePowerShell(),按 pwsh → powershell 实际探测并缓存
8沙箱启动失败时异常从 run() 冒泡,调用方无法区分"沙箱坏了"与"命令被拒"launcher.launch() 未包 try/catch,违反手册第 4 章返回契约失败时返回 exitCode:127 + launchFailed:true + runner-failure 分类
9超时被误分类为 runner-failureclassifyOutcome 先匹配裸前缀 /dsh-stage: /,而超时消息也带该前缀先认最具体标记(dsh-stage: timeout after),再认通用前缀
10替身无法被发现:captureLaunchState 要求令牌必须是 bigint/object类型假设过强;真正证明令牌有效的是随后能带着它 spawn 成功改为按字段名匹配、不假设类型
11读取面 10 项全部假通过(显示"拒绝读取 unknown"),写入面全 fail,TEMP=undefined审计把"脚本没有产出任何输出"当成了"操作被拒绝";读取面在结果里找不到路径时也判 readable=false → status='pass'新增预检门(先验证 shell 能否产出输出)+ 输出哨兵 DSH-AUDIT-JSON: + 读取面改按序号回传布尔值;空结果一律判 fail
12审计模块自身被编码破坏:255 处 mojibake、行被合并、字符串字面量引号被截断我用 PowerShell 做 Get-Content -Raw → Set-Content -Encoding UTF8 文本往返,读取时未指定 UTF-8以正确 UTF-8 重写;新增编码自检。绝不用 shell 文本往返修改含非 ASCII 的源码
13CreateProcessAsUserW failed (Win32 87 = ERROR_INVALID_PARAMETER),launchFailed=true,子进程根本没创建注入 UTF-16LE 环境块时没有同时置 CREATE_UNICODE_ENVIRONMENT (0x400)。库内部以 creationFlags = 0 调用(其受限路径本来传 lpEnvironment = NULL),我们补了环境块却丢了这个必须成对出现的标志注入分支上 flags |= 0x400;未注入的调用不改 flags
14AssignProcessToJobObject failed (1816 = ERROR_NO_SYSTEM_RESOURCES),即 Job 配额耗尽,任何命令都起不来① init() 自检把宿主自身进程挂进了常驻 Job;② Job 设了 ACTIVE_PROCESS 上限(曾默认 32)。自身占掉名额后,再挂沙箱子进程就越界自检改用临时 Job(建→查→即释放),不把宿主进程挂进任何 Job;默认不设 ACTIVE_PROCESS 上限;init() 断言常驻 Job 启动时为空
15child.wait is not a function;子进程已创建但拿不到输出调用了不存在的 API:spawnPipedProcess 返回 { pid, process, stdoutRead, stderrRead },没有 wait();管道要用 drainPipe,退出码要用 waitForProcessExit新增 collectChild() 用真实 API 收集结果;缺失即 fail-closed。并修正了失真的替身
16预检回报 子进程关键变量=undefinedparseMarkedJson() 把哨兵硬编码成 AUDIT_MARKER,而预检用的是 DSH-PREFLIGHT-…,查找永远不匹配参数化哨兵 parseMarkedJson(stdout, marker = AUDIT_MARKER);断言"不传哨兵时解析不出自定义哨兵"以证明该参数必需

十一条方法论教训:

  • 缺陷 4:归因纪律。 审计原先把"沙箱建立失败"一律解释成"嵌套不可用", 于是一个代码缺陷被伪装成环境限制。若不核对,它会被永久归因给令牌。
  • 缺陷 5:不要凭记忆写结构体布局。 该错误连过两轮审计,因为受限会话跑不到那一步。
  • 缺陷 6+7:命令能不能起来,必须被独立测试。
  • 缺陷 8+9:编排层必须能被离线测试。
  • 缺陷 11(最严重):空结果不是拒绝。 它让审计报出了一组完全错误的结论—— 10 项"读取被拒绝"全是假通过。这比"探测失败"危险得多,因为它伪装成证据。 也正是它让缺陷 13、14、15 在此前永远无法被发现。
  • 缺陷 12:工具选择错误。 用 shell 往返文本处理非 ASCII 源码是自伤行为。
  • 缺陷 13:补参数别漏配套标志。 一路走到第 8 轮才暴露。
  • 缺陷 14:自检必须是只读的。 "把自身挂进 Job 再查询"改变了运行期状态, 反而制造了它本要预防的故障(手册第 17.2 节同一道理)。
  • 缺陷 15:替身失真比没有替身更危险。 我给假 child 伪造了真实库并不存在的 wait(), 于是离线测试全绿、真实环境立刻炸。替身必须逐字段对齐真实 API, 宁可让它复杂,也不要让它比真实对象"更好用"(与手册第 15.5 节同类错误)。

失败点逐层后移的时间线(1 批处理 → 2/4 绑定表 → 3 Job 长度 → 5 结构体偏移 → 6/7 命令解析 → 11/12 结论可信性 → 13 创建参数 → 14 Job 配额 → 15 结果收集 API)说明: 把"假通过"改成"明确失败 + 可定位诊断"是整个工作的转折点。

现在有 6 组不依赖 Win32 的确定性测试,一条命令即可全跑:

.\verify.cmd     # 25 套件(清单 = OFFLINE_SUITES);权威表与断言数口径见 §8.2
测试项数覆盖
tests/selftest.mjs51手册不变量:统一视图、删除权威、候选冻结、选择性应用、遮蔽、返回语义、测试卫生(不残留重解析点)
tests/e2e-flow.mjs16捕获 → 冻结 → diff → 选择性提交全链路
tests/struct-layout.mjs24结构体大小/偏移/越界、扩展限制、环境块编码
tests/appcontainer-layout.mjs27AppContainer 布局:STARTUPINFOEX / SECURITY_CAPABILITIES / 创建标志 / 属性号
tests/resolve-exec.mjs19可执行文件解析、PATHEXT 回退、子环境含 PATH
tests/executor-stub.mjs72init 装配、spawn 契约、0x400 标志、Job 配额/自检只读性、真实结果收集形状、环境注入、fail-closed、超时回收、dispose
tests/audit-parse.mjs23哨兵解析、空输出必须判 fail、单引号转义、自定义哨兵
tests/meta-runner.mjs4元测试:运行器能否检出失败
tests/diag-bindings.mjs14绑定表契约 + 完整 init

⚠️ 上表是第二轮之前的旧口径,已被 §8.2 的权威表取代:executor-stub 72 → 84、audit-parse 23 → 43、 appcontainer-layout 27 → 34、e2e-flow 16 → 21、command-selftest(§7)22 → 24, 并新增 paths-masks 37 / registry-guard 125 / workspace-regressions 10。 旧文"verify.cmd 现为 11 套件 / 452 断言;autotest --skip-audit 为 12 套件 / 474 ok"是第二轮时点口径, 第三轮起 verify.cmd 覆盖 20 个离线套件(含网络策略 / 进程缓解 / 资源上限 / WFP 布局 / 两个治理套件), 本轮 C3 追加 policy-never-consistency ⇒ 21 个;此后「补丁工具包 + 源码封印」轮 ⇒ 23、 缺陷③轮 ⇒ 24、缺陷①b轮 ⇒ 25(断言数已由 finisher 全量复跑填入:2034 标记 / RESULT: ALL PASS —— 见 §8.2)。

详见 docs/实测证据记录.md §C.2。


5. 档位与实现状态

5.1 各 Windows 原语的实现状态(诚实区分"已实现/已探测/未实现")

本项目的目标里点名了 5 类 Windows 原生能力。必须逐类说清到底做到哪一步, 不能让"档位表里出现过 AppContainer"被读成"AppContainer 已经能用":

能力状态说明证据
Restricted Token(WRITE_RESTRICTED)✅ 已实现并实测复用 AclSandbox 构造的受限令牌 + 受限 SID 交集=唯一可写根;在此基础上自行注入显式环境块与 Job 归属[实测] 写入面 7 项 + tests/executor-stub.mjs 72 项
ACL(DACL 授权 + deny FILE_DELETE_CHILD + Low IL 标签)✅ 已实现并实测由 @deepseek-ai/dsh-sandbox-windows-acl 施加;越界写/删实测全部 denied[实测] audit 写入面
Job Object(KILL_ON_JOB_CLOSE)✅ 已实现并实测自建 Job,子进程挂入;会计自检(精确 48 字节)在 init() 内强制;超时即终止整树[实测] audit 生命周期段(总进程数增量 1)
AppContainer✅ 运行期已实测生效(proven=true)且已接线到执行器;但默认档位仍是 T1,selectTier() 的 T0 闸门仍要求 proven === truesrc/appcontainer.mjs + src/appcontainer-runtime.mjs 提供 STARTUPINFOEX / SECURITY_CAPABILITIES / 属性列表构造,并新增权威判据(TokenIsAppContainer=29 / TokenAppContainerSid=31 / TokenIntegrityLevel=25)与 assessAppContainerIsolation()(五项判据缺一即 false,无硬编码、无跨 profile 复用)。[实测] 真实子进程:IsAppContainer=1、包 SID 一致、Low IL、区外写被拒(宿主对照成功)、caps=0 时 curl exit 7 ⇒ proven=true。离线布局测试 34 项(tests/appcontainer-layout.mjs)[实测] 端到端隔离证据(.t/sbx3/dev/raw-t0-isolation-proof.txt);接线与默认档位的准确说法见下方 §5.1 与 §8
WFP(Windows Filtering Platform)⚠️ 结构/调用层与策略层已实现,OFFLINE 档位 fail-closed;真实安装 [未实测]src/wfp.mjs 提供 FWPM_FILTER0 / FWPM_SUBLAYER0 / FWPM_SESSION0 / FWPM_FILTER_CONDITION0 等结构的构造与 Fwpm* 调用编排;src/netpolicy.mjs 把它接成策略:前置能力(绑定表 / pin 回调 / 可用性探测 / 引擎可开)任一缺失即 refused,enforced:true 只在"过滤器确实装上且回读核对通过"时出现。离线证据:tests/wfp-layout.mjs(合成缓冲区钉死布局)+ tests/netpolicy.mjs(替身绑定表钉死五条不变量)+ tests/residual-baseline.mjs。但本机从未安装过任何真实过滤器 —— 安装过滤器是系统级状态变更,本阶段未获授权离线 [实测](合成缓冲区 / 替身绑定表);真实 BFE 行为与端到端阻断 [未实测]。差距来源:docs/NeoAI-沙箱能力分析与差距输入.md §3.3 / §5.3(要求内核层出站过滤)与 docs/WinStageSandbox-能力清单与差距基线.md §2(网络面 ABSENT)

因此:

  • T0 的隔离已 [实测] 生效,T0 也已接进执行器;但默认档位仍然是 T1。 准确说法(逐条,别再互相矛盾): ① T0 已经接线:显式请求 tier: 'T0' 时,src/executor.mjs:3380-3382(selectLaunchMode('T0') → 'appcontainer')、 :1544-1547(写入 launchMode)、:1669-1704(装配 createAppContainerLauncher,失败即 SANDBOX_UNAVAILABLE、不静默回退 T1)、:2181-2192(T0 启动分支)真的会走 AppContainer 路径; ② 但默认选不到 T0:执行器默认 this.tier = options.tier || 'T1'(src/executor.mjs:1304), 只有调用方显式点名 T0 才会用; ③ 能力报告的档位建议 selectTier() 的 T0 闸门逐字仍是 report.appContainerIsolation?.proven === true(src/capability.mjs:1436,四条同时成立见 :1462), 而 proven 需要跑一次真实隔离探针 —— 没有探针结果时 T0 一律 fail-closed。 因此"默认路径跑在 T1"是档位默认值 + selectTier() 闸门两件事的结果, 不是"T0 没接线";反过来,proven=true 也不等于"沙箱已经默认用上隔离"。
  • AppContainer 布局已用合成缓冲区离线钉死(34 项),这是从缺陷 5(Job 结构体偏移错 4 字节) 与缺陷 13(漏 CREATE_UNICODE_ENVIRONMENT)总结出的做法:先把布局测对,再谈运行。
  • 网络面:策略层已存在,真实强制仍是 [未实测]。src/netpolicy.mjs + src/wfp.mjs 的 OFFLINE 判定是 fail-closed(能力不足即 refused,绝不 enforced:true),但本机未安装任何真实 WFP 过滤器 ⇒ 沙箱内实际仍可开 socket,"已断网"的说法依旧不成立(R2)。详见 §9.1。 (补:T0 自己的网络阻断只是"不声明能力"的默认值——一旦声明 internetClient 网络即通, 所以能力集必须由策略层白名单化,不能由调用方随手传。)
  • 旧结论更正:早先由本表读出的"AppContainer 探测 E_ACCESSDENIED ⇒ 隔离为零 / 进不了容器" 已被推翻 —— 那是判据错误(拿 TokenUser 比包 SID,而 AppContainer 令牌的 TokenUser 本来就是用户 SID,包身份在 TokenAppContainerSid(31),且包 SID 不在 TokenGroups 里)。 详见 §8 与 .t/sbx3/dev/03-FIX-B报告.md。

5.2 档位表

档位机制读取面网络面前置条件本项目实现状态
T0 appcontainerAppContainer 能力令牌 + Job + Low IL + ACL✅ 收敛✅ 默认阻断能创建 AppContainer profile⚠️ 隔离已 [实测] 生效(proven=true)且已接进执行器;但默认档位是 T1(显式 tier:'T0' 才走 T0),selectTier() 的闸门仍要求 proven===true(见 §5.1 与 §8)
T1 restricted-tokenWRITE_RESTRICTED + Job + Low IL + ACL❌ 不收敛❌ 不收敛令牌具备全部访问权✅ 已实现并实测
T2 acl-only仅 ACL 写边界❌❌目录属主✅ 已实现(降级路径)
T3 none无可用原语———✅ 拒绝执行(fail-closed)

档位由 selectTier() 依据真实 Win32 调用结果选择,不据文档推断。


6. 已知残余边界

完整清单见 docs/Windows功能开启清单.md 第 13 节(R1–R12)。最关键几条:

  • R1 读取面不收敛:本后端限制写/删,不限制读。读取面收敛只能靠 AppContainer (T0 已实现、隔离 [实测] 生效、且已接进执行器,但默认档位是 T1 —— 见 §5.1/§5.2) 或工具层硬拒绝清单。
  • R2 网络面不收敛:网络策略层已存在且 OFFLINE 档位 fail-closed,但本机未安装真实过滤器 ([未实测])⇒ 沙箱内实际仍可开 socket。见 §5.1、§9.1。
  • R6 嵌套隔离不可用:已受限进程无法再建受限令牌 → 沙箱必须由未受限宿主进程建立。
  • R7 环境块需运行时注入:依赖包的受限 spawn 固定传 lpEnvironment=NULL(继承父环境, 违反手册 #8.3);本实现替换绑定表注入显式环境块,并在结构不符时 fail-closed 拒绝启动, 绝不静默退回继承父环境。

7. DSH 集成:暂存审批悬浮窗

把本沙箱接进 DeepSeek Harness,使 DSH 自己的 write/edit 工具也落暂存树, 并在输入框上方给出「待审文件 + 逐文件 diff + 批准/拒绝」的悬浮窗:

  • 装配与数据通路(为什么用 review.json + /winstage 命令而不是新的 Remote 命名空间)、 实测证据、已知崩溃风险(在线切换该 bundle 会让宿主进程退出,改配置后请重启)、 残余边界(shell 写入不经过暂存等):见 docs/DSH集成.md。
  • 三套可重复跑的离线自测:.t/review-selftest.mjs(26 项)、 .t/staging-fs-selftest.mjs(31 项)、.t/command-selftest.mjs(22 项)。
  • /winstage [list|diff|approve|reject|refresh] 是人工侧的批准入口;命令不产生模型消息。

7.1 第二实例 + 真实浏览器实测(2026-09-28)

在独立的第二个 DSH 实例(另一端口、独立 DSH_HOME)上装了本插件,用真实浏览器(CDP) 把面板看得见、点得动逐条验过。结论摘要,证据见 需求与验收 / 基线报告 / 修复报告 / 发现台账:

  • 面板确实出现(headless + 有头双证据,截图在 .t/dsh2/browser/); 「批准所选」后 review.json 变化且文件真的落盘,「暂时收起」后消失。
  • 修掉 7 个缺陷,其中两个是"看不见就永远查不出来"的: ① loader 行名用子路径 specifier ⇒ client 半永不下发(面板永远不出现,刷新无效); ② 命令定义写成 input: { placeholder } 而校验器要 hint ⇒ 注册抛错 ⇒ /winstage* 一条都没注册 ⇒ 面板的批准/拒绝静默失效。
  • 两条硬规则(改 composition 前必读):host 行必须用裸包名、fs 行必须保留子路径; 且 profile 覆盖层必须重述该行 config 的所有键(config 是整体替换、不是深合并, 漏写的键静默消失)。
  • 环境事实:本 Agent 沙箱内起不了 Chromium(Mojo 要命名管道),浏览器验收必须在 沙箱外的终端启动浏览器再用 CDP 操作 —— Runbook 见 .t/dsh2/S5-浏览器Runbook.md。

本节写于当时,其中一条已经作废(整理轮更正):原文写"改动尚未提交 git(本环境 PATH 上没有 git)",而本机确有 git 2.55,且 dsh-plugin/ 下的 cordis.patch.yml、 host-plugin.mjs、client.js、staging-fs.mjs、fs-entry.mjs 已随后续 "整理仓库并纳入插件与测试"入版本库。整理轮(测试套件入库)又把此前一直未跟踪的 tests/*.mjs、tools/*.mjs、src/ 与 dsh-plugin/ 的漏项、patches/ 一并入库, 详见 §11 与 .gitignore 头部的两条设计原则。


8. 第二轮修复(FIX