dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
DSH plugin: Windows staging-candidate-selective-commit sandbox (files + registry) with a review panel
按《Agent 沙箱暂存—提交架构设计手册 v3.0》实现,面向 Windows + DeepSeek Harness。
核心思路:把"隔离"和"暂存"合并成同一件事——沙箱唯一可写根是暂存树, 真实工作区始终不被写入;沙箱内产生的变化冻结为候选,用户批准后才逐文件提交。
# npm(已发布时)
dsh plugin --profile web add dsh-winstage-sandbox
# 或直接从 GitHub 安装(无需 npm)
dsh plugin --profile web add github:gygygfg/dsh-winstage-sandbox
WINSTAGE_SKIP_ENV_GATE=1)。SandboxedFileSystem,命令面在装配期
交还平台 pwsh-sandbox(运行中切换需重启宿主)。详见 docs/插件启动-环境监测与开关保证.md。node tools/fetch-toolchain.mjs 再 node tools/build-shim.mjs。PUBLISHING.md。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(需未受限会话)
autotest.cmd / autotest.mjs一次运行 25 个离线套件 + 4 个沙箱套件 + 1 段沙箱审计,分三段,并写出机器可读报告:
| 段 | 套件 | 需要未受限会话 | 说明 |
|---|---|---|---|
| A | 25 组离线确定性测试(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 提升) |
| B | 4 个沙箱套件: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) |
| C | audit | 是 | 沙箱内真实攻击探针(结构化 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 记录。
tests/meta-runner.mjs 用"人为失败的子进程"反证运行器的判定逻辑。
一个永远不会报失败的运行器比没有运行器更糟 —— 它给出虚假的绿色。
(本项目已经踩过一次"假通过"的坑:缺陷 11,见 docs/实测证据记录.md。)
# 初始化暂存区
.\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
不想守着终端时,可以起一个常驻服务,收到请求就跑测试并返回结果。
# 启动服务(默认 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>
| 方法 | 路径 | 说明 |
|---|---|---|
| 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 | 只监听回环 | 默认 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,或用后台作业方式启动。
┌─────────────────────────────────────────────────────────────────────┐
│ 逻辑视图(对 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 | 两侧会话的实测证据、缺陷复盘、仍未提供的保证 |
| 手册条款 | 不变量 | 实现位置 | 自测用例 |
|---|---|---|---|
| 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_UNAVAILABLE | SANDBOX_UNAVAILABLE 路径实测 |
| 6 / 7 | 一个 attempt 一条物化路径;删除捕获完整 | materializeForExecution() + captureAfterExecution() | 删除型变更应用后真实消失 |
| 8.3 | 环境从允许清单重建,禁止 merge | buildChildEnvironment() | 审计 secret-env-blocked / proxy-env-blocked |
| 12.1 | 无净变化不入队 | freezeCandidate() 提前返回 | 「无净变化时不入队」 |
| 12.2 | 部分应用不丢其余 | applyCandidate() 保留 remaining | 选择性应用 4 项 |
| 12.3 | 陈旧引用重定向 + 幂等 | resolveCandidate() 沿链 + 环路保护 | 「陈旧候选可沿 superseded_by 链解析」 |
| 12.5 | 丢弃是有状态操作 | discardCandidate() 先落状态后回收 | 丢弃 3 项 |
| 12.6 | diff 两侧都在候选里冻结 | blob 内容寻址 + 冻结哈希 | 「候选冻结了 before 与 after 两侧内容」 |
| 12.7 | 待审计数按文件 | summary.files / pendingFiles | status 输出 |
| 13.1 | 清理前先修复权限,只作用已验证归属 | makeRemovable() 不跟随链接 | GC 2 项 |
| 16.6 | 遮蔽前先规范化,符号链接不能绕过 | maskOf() 先 canonical 再判豁免 | 「junction 逃逸仍被遮蔽」 |
| 16.7 | 硬边界先于可协商项;覆盖所有路径参数 | assertToolPaths() 先遮蔽后边界 | 「经 junction 读取被硬拒绝」 |
| 16.8 | 豁免限定作用域,不含自己 | 工作区内才豁免;.dshstage 永不豁免 | 豁免 2 项 |
自测结果:48 / 48 通过。 详细报告:.t/selftest-report.json。
按手册第 17 章纪律,失败用例暴露的是实现缺陷而非测试问题:
maskOf() 先查遮蔽表、后用 isInside() 判豁免,
而 isInside() 内部会 canonical 化 → 工作区内指向宿主敏感目录的 junction
被误判为"在工作区内"而获得豁免。修复:先 canonical 解析,只有解析后仍在
工作区内才谈豁免。(手册 #16.6 / #16.8)relative() 用 canonical 判定,
导致 junction 逃逸时直接抛异常,遮蔽表没有机会给出 SANDBOX_PATH_MASKED。
修复:新增 lexicalInside(),边界判定用词法,逃逸交给遮蔽判定。(手册 #16.7)exists() 经 relative() 抛
PATH_OUTSIDE_WORKSPACE,而不是返回不可见。修复:存在性用词法判定,
解析后逃逸返回 source: 'masked' 且不暴露解析目标。其余 13 个缺陷(编号 4–16)见下面 §4 的完整清单。
关键结论:同一份代码在受限会话内无法建立沙箱、在未受限会话内可以。 这从两侧实测证明了"Windows 进程隔离不可嵌套"。
汇总: {"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。
| # | 症状 | 根因 | 修复 |
|---|---|---|---|
| 1 | run.cmd 报 'WRITE_RESTRICTED' 不是内部或外部命令 | 批处理含 UTF-8 中文注释,cmd.exe 按 OEM 代码页解析并当成命令执行 | run.cmd 改为纯 ASCII |
| 2 | this.api.spawnPipedProcess is not a function | 我捕获的是 AclSandbox 的私有安全绑定表,它不含进程库原语 | 新增 mergeBindingTables():以低层表为基底,只补入 ACL 独有扩展 |
| 3 | QueryInformationJobObject failed (24 …长度不正确) | 该 API 要求长度恰好等于结构大小;原实现传 64(实际 48) | 实测确定 48 并固化 |
| 4 | 报 did not expose spawnPipedProcess,且被错误归因为令牌不足 | 混为一谈:loadWin32ProcessBindings() 返回低层表(从不含 spawnPipedProcess),而 spawnPipedProcess(api, options) 是模块级函数 | 三者分离取用;审计归因改造:只有确实因令牌权限不足才标 not-run,其余一律标 fail |
| 5 | offset is out of range … Received 48 | 我凭记忆写的结构体偏移整体错 4 字节(应为 32/36/40/44,我写成 32/40/44/48) | 修正偏移;抽出 parseBasicAccounting() + 边界断言;新增不依赖 Win32 的 tests/struct-layout.mjs |
| 6 | CreateProcessAsUserW 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-failure | classifyOutcome 先匹配裸前缀 /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 的源码 |
| 13 | CreateProcessAsUserW failed (Win32 87 = ERROR_INVALID_PARAMETER),launchFailed=true,子进程根本没创建 | 注入 UTF-16LE 环境块时没有同时置 CREATE_UNICODE_ENVIRONMENT (0x400)。库内部以 creationFlags = 0 调用(其受限路径本来传 lpEnvironment = NULL),我们补了环境块却丢了这个必须成对出现的标志 | 注入分支上 flags |= 0x400;未注入的调用不改 flags |
| 14 | AssignProcessToJobObject failed (1816 = ERROR_NO_SYSTEM_RESOURCES),即 Job 配额耗尽,任何命令都起不来 | ① init() 自检把宿主自身进程挂进了常驻 Job;② Job 设了 ACTIVE_PROCESS 上限(曾默认 32)。自身占掉名额后,再挂沙箱子进程就越界 | 自检改用临时 Job(建→查→即释放),不把宿主进程挂进任何 Job;默认不设 ACTIVE_PROCESS 上限;init() 断言常驻 Job 启动时为空 |
| 15 | child.wait is not a function;子进程已创建但拿不到输出 | 调用了不存在的 API:spawnPipedProcess 返回 { pid, process, stdoutRead, stderrRead },没有 wait();管道要用 drainPipe,退出码要用 waitForProcessExit | 新增 collectChild() 用真实 API 收集结果;缺失即 fail-closed。并修正了失真的替身 |
| 16 | 预检回报 子进程关键变量=undefined | parseMarkedJson() 把哨兵硬编码成 AUDIT_MARKER,而预检用的是 DSH-PREFLIGHT-…,查找永远不匹配 | 参数化哨兵 parseMarkedJson(stdout, marker = AUDIT_MARKER);断言"不传哨兵时解析不出自定义哨兵"以证明该参数必需 |
十一条方法论教训:
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.mjs | 51 | 手册不变量:统一视图、删除权威、候选冻结、选择性应用、遮蔽、返回语义、测试卫生(不残留重解析点) |
tests/e2e-flow.mjs | 16 | 捕获 → 冻结 → diff → 选择性提交全链路 |
tests/struct-layout.mjs | 24 | 结构体大小/偏移/越界、扩展限制、环境块编码 |
tests/appcontainer-layout.mjs | 27 | AppContainer 布局:STARTUPINFOEX / SECURITY_CAPABILITIES / 创建标志 / 属性号 |
tests/resolve-exec.mjs | 19 | 可执行文件解析、PATHEXT 回退、子环境含 PATH |
tests/executor-stub.mjs | 72 | init 装配、spawn 契约、0x400 标志、Job 配额/自检只读性、真实结果收集形状、环境注入、fail-closed、超时回收、dispose |
tests/audit-parse.mjs | 23 | 哨兵解析、空输出必须判 fail、单引号转义、自定义哨兵 |
tests/meta-runner.mjs | 4 | 元测试:运行器能否检出失败 |
tests/diag-bindings.mjs | 14 | 绑定表契约 + 完整 init |
⚠️ 上表是第二轮之前的旧口径,已被 §8.2 的权威表取代:
executor-stub72 → 84、audit-parse23 → 43、appcontainer-layout27 → 34、e2e-flow16 → 21、command-selftest(§7)22 → 24, 并新增paths-masks37 /registry-guard125 /workspace-regressions10。 旧文"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 类 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 === true | src/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 也已接进执行器;但默认档位仍然是 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 也不等于"沙箱已经默认用上隔离"。CREATE_UNICODE_ENVIRONMENT)总结出的做法:先把布局测对,再谈运行。[未实测]。src/netpolicy.mjs + src/wfp.mjs 的 OFFLINE
判定是 fail-closed(能力不足即 refused,绝不 enforced:true),但本机未安装任何真实 WFP 过滤器
⇒ 沙箱内实际仍可开 socket,"已断网"的说法依旧不成立(R2)。详见 §9.1。
(补:T0 自己的网络阻断只是"不声明能力"的默认值——一旦声明 internetClient 网络即通,
所以能力集必须由策略层白名单化,不能由调用方随手传。)E_ACCESSDENIED ⇒ 隔离为零 / 进不了容器"
已被推翻 —— 那是判据错误(拿 TokenUser 比包 SID,而 AppContainer 令牌的 TokenUser
本来就是用户 SID,包身份在 TokenAppContainerSid(31),且包 SID 不在 TokenGroups 里)。
详见 §8 与 .t/sbx3/dev/03-FIX-B报告.md。| 档位 | 机制 | 读取面 | 网络面 | 前置条件 | 本项目实现状态 |
|---|---|---|---|---|---|
T0 appcontainer | AppContainer 能力令牌 + Job + Low IL + ACL | ✅ 收敛 | ✅ 默认阻断 | 能创建 AppContainer profile | ⚠️ 隔离已 [实测] 生效(proven=true)且已接进执行器;但默认档位是 T1(显式 tier:'T0' 才走 T0),selectTier() 的闸门仍要求 proven===true(见 §5.1 与 §8) |
T1 restricted-token | WRITE_RESTRICTED + Job + Low IL + ACL | ❌ 不收敛 | ❌ 不收敛 | 令牌具备全部访问权 | ✅ 已实现并实测 |
T2 acl-only | 仅 ACL 写边界 | ❌ | ❌ | 目录属主 | ✅ 已实现(降级路径) |
T3 none | 无可用原语 | — | — | — | ✅ 拒绝执行(fail-closed) |
档位由 selectTier() 依据真实 Win32 调用结果选择,不据文档推断。
完整清单见 docs/Windows功能开启清单.md 第 13 节(R1–R12)。最关键几条:
[实测] 生效、且已接进执行器,但默认档位是 T1 —— 见 §5.1/§5.2)
或工具层硬拒绝清单。[未实测])⇒ 沙箱内实际仍可开 socket。见 §5.1、§9.1。lpEnvironment=NULL(继承父环境,
违反手册 #8.3);本实现替换绑定表注入显式环境块,并在结构不符时 fail-closed 拒绝启动,
绝不静默退回继承父环境。把本沙箱接进 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] 是人工侧的批准入口;命令不产生模型消息。在独立的第二个 DSH 实例(另一端口、独立 DSH_HOME)上装了本插件,用真实浏览器(CDP)
把面板看得见、点得动逐条验过。结论摘要,证据见
需求与验收 / 基线报告 /
修复报告 / 发现台账:
.t/dsh2/browser/);
「批准所选」后 review.json 变化且文件真的落盘,「暂时收起」后消失。input: { placeholder } 而校验器要 hint ⇒ 注册抛错 ⇒
/winstage* 一条都没注册 ⇒ 面板的批准/拒绝静默失效。config 是整体替换、不是深合并,
漏写的键静默消失)。本节写于当时,其中一条已经作废(整理轮更正):原文写"改动尚未提交 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头部的两条设计原则。