diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 0b2036ca86..0c0d28aa76 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: 45a1c9c028f127c67e56f4436088852f59e10140 -2026-07-06-sandbox.zh.md: 6ad51a039583949849c853fab2f7c2a72d6d9db7 +2026-07-06-sandbox.md: f2c703ba67bc16529605250d35ed181412f05494 +2026-07-06-sandbox.zh.md: ff4d4ed592dc4235903555528a029b4b295d972e diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 45a1c9c028..f2c703ba67 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -176,7 +176,7 @@ Costs and accepted limits: - **The one-wrapper illusion is given up knowingly.** A `tools/pre-execute` wrapper plus prompt conventions does not solve sandbox approval — the correct design costs structured denials, native runner probes, per-call policy carriage, and consistent cross-family enforcement, and this design pays it. - **`read-only` became a cross-family boundary through a follow-up.** This RFC shipped bash-only enforcement; the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md) extends the same mode vocabulary to the filesystem tools through a sandboxed `ctx.fs` provider and relocates the mode/root config and the `sandbox/mode` override to `ctx.sandboxPolicy` (§ In-process tools). -- **Windows is a partial backend.** This RFC originally reserved an empty, fail-closed win32 chain; the later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled it with the restricted-token runner. Its Everyone and hard-link gaps are reported as `enforcement: 'partial'`, never promoted to the full promise. +- **Windows is a partial backend.** This RFC originally reserved an empty, fail-closed win32 chain; the later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled it with the restricted-token runner. Its hard-link, unconfined-read, and AppContainer-ACL gaps are reported as `enforcement: 'partial'`, never promoted to the full promise. - **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal under a usable workdir surfaces as a runner-attributable spawn failure and an executable refusal through its fatal signature — both become `SANDBOX_UNAVAILABLE`, and the command never runs; fail closed, never open. - **Landlock confinement is only as complete as the running kernel's ABI.** Reported as `enforcement: 'partial'` rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts. - **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 6ad51a0395..ff4d4ed592 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -176,7 +176,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **单一包装层的幻觉被有意放弃。**`tools/pre-execute` 包装层加提示词约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。 - **`read-only` 通过后续设计成为跨工具族边界。** 本 Agent Note 最初只交付 bash 强制;[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.zh.md) 通过沙箱化的 `ctx.fs` 提供方把同一模式词汇扩展到文件系统工具,并将 mode/root 配置和 `sandbox/mode` 覆盖迁移到 `ctx.sandboxPolicy`(§ 进程内工具)。 -- **Windows 后端只提供部分强制执行。** 本 RFC 最初预留了一条空的、失败关闭的 win32 链;后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)以受限令牌 runner 填充了它。其 Everyone 与硬链接缺口报告为 `enforcement: 'partial'`,绝不提升为完整承诺。 +- **Windows 后端只提供部分强制执行。** 本 RFC 最初预留了一条空的、失败关闭的 win32 链;后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)以受限令牌 runner 填充了它。其硬链接、读取不受限与 AppContainer ACL 缺口报告为 `enforcement: 'partial'`,绝不提升为完整承诺。 - **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此在 workdir 可用时,未来移除会表现为可归因于 runner 的 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`,且命令绝不会运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 596033c717..65bc426f49 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 9834e9b559f0a8045b0d36dd2dcfba864238996b -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: d349b504ca6a1054237d5ca6aa61547c2a0dcdad +2026-08-08-windows-acl-restricted-token-sandbox.md: 65780b9bba31605db02dc9be4c1b2454c74eb0f4 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: fe9aadb97b172d77d65ca6b0a843f05751075502 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 9834e9b559..65780b9bba 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The original [sandbox decision](2026-07-06-sandbox.md) left `PLATFORM_CHAINS.win ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Because Everyone remains, an external object granting Everyone write access clears both checks; because NTFS ACLs belong to file objects, a granted workspace hard link also grants an external alias. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. ## How the restriction works (why no new identity) @@ -32,12 +32,14 @@ The landstrip evaluation was rejected before implementation (not battle-tested; ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because Everyone-granted writes and NTFS hard-link aliases cannot be path-confined by this token shape; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../bug-fix/2026-09-16-windows-subprocess-console-visibility.md) preserves console inheritance); standing workspace ACE mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets still writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; `whoami` and token-inspection cmdlets failing under the restricted token; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../bug-fix/2026-09-16-windows-subprocess-console-visibility.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. ## Testing -The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins real DACL lifecycle; and `runner.spec.ts` pins paired-SID validation, sibling temp denial under a shared workspace SID, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public denial, Everyone/hard-link partial boundaries, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. +The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins the real DACL, ambient-delete deny, and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, paired-SID validation, sibling temp denial under a shared workspace SID, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, the cross-root delete regression against another workspace and another live temp tree, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. ## Related The [pwsh executor decision](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md) owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes. + +The [delete-confinement decision](2026-09-19-windows-acl-mandatory-integrity-confinement.md) extends this rung with the Low integrity layer and the ambient-delete deny; it supersedes only the delete route documented here. diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index d349b504ca..fe9aadb97b 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。由于 Everyone 仍在列表中,向 Everyone 授予写访问的外部对象会通过两次检查;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接也会使同一对象的外部别名获得授权。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这两个缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。 ## How the restriction works (why no new identity) @@ -32,12 +32,14 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher ## 后果 -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致),且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md)保留控制台继承);工作区常驻 ACE 改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标仍可写;NULL-DACL 目录在 grant/revoke 往返下不保持身份;`whoami` 与令牌检查 cmdlet 在受限令牌下失败;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 ## 测试 -产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL 生命周期;`runner.spec.ts` 钉住成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 拒绝、Everyone/硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 +产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL、环境性删除拒绝与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、针对另一个工作区与另一棵活跃临时目录树的跨根删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 ## Related [pwsh 执行器决策](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。 + +[删除约束决策](2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md)以 Low 完整性层与环境性删除拒绝扩展本档;它只取代本文档所述的删除路径。 diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml index c91b5f6407..a2eba8f020 100644 --- a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md -2026-09-14-docs-mermaid-viewer.md: 03aecb7b4df1c79d2eb254375f6b2c68145079ac -2026-09-14-docs-mermaid-viewer.zh.md: b66f427a4820eef0d6fa25b163cfc8e3548ec0c6 +2026-09-14-docs-mermaid-viewer.md: 5cb4bae84fcafb49c559b7678e9a9dc1aa961ab0 +2026-09-14-docs-mermaid-viewer.zh.md: 665ab65e48e15651ebb9323e96e6a950c0b48deb diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md index 03aecb7b4d..5cb4bae84f 100644 --- a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md @@ -1,4 +1,4 @@ -# Agent Note: Documentation Mermaid viewer +# Agent Note: Documentation diagram and image viewer Status: implemented @@ -6,15 +6,17 @@ English | [中文](2026-09-14-docs-mermaid-viewer.zh.md) ## Problem -Complex Mermaid diagrams lose readable detail when scaled to the documentation column. Long sequence diagrams need both magnification and movement to inspect interactions while retaining an overview. +Complex Mermaid diagrams and interface screenshots lose readable detail when scaled to the documentation column. Readers need both magnification and movement to inspect interactions while retaining an overview. ## Decision The [VitePress theme](../../../../website/.vitepress/theme/index.ts) adds a corner fullscreen icon to each rendered Mermaid SVG. A native modal dialog provides an inert background and Escape dismissal. Its visible title also names it for assistive technology; a missing or blank page heading uses the localized viewer title. A floating toolbar groups zoom controls, the current scale, and fit; close stays in the top corner, and help opens on demand. Keyboard focus cycles through all five buttons. Panzoom supplies pointer, wheel, and pinch interaction. Arrow keys pan in fixed screen distances. Closing restores the entry's focus without scrolling and restores the page's previous overflow setting. +Ordinary images share one modal and zoom controller with Mermaid. Loaded images with nonempty alt text that occupy their own paragraph open through an image click or corner button; linked, inline, decorative, and `data-no-zoom` images retain their existing interaction. Image alt text names the modal, and the scale button returns to centered natural size (100%) while its accessible name also includes the current scale; keyboard focus cycles through six buttons in image views. + The viewer copies the SVG into a shadow root. Mermaid's embedded selectors and fragment IDs stay local to the copy, so its markers and styles cannot resolve against the original diagram. Panzoom transforms a viewport-sized canvas containing the SVG at its natural viewBox dimensions, so pointer coordinates and the transform origin share the same center; the initial scale and every resize fit the entire diagram without enlarging it beyond its natural size. This preserves vector detail while keeping fit independent of the narrow document column. The canvas has no visible frame; reserved space keeps controls clear of the fitted diagram. -Viewer resources belong to the mounted theme. Route, language, theme, and source-SVG replacement close the active view; asynchronous Mermaid renders receive a fresh entry. The body overflow lock relies on the default theme retaining visible overflow on the HTML element. It avoids mutating HTML attributes, which the Mermaid plugin observes and rerenders in response. The [implementation](../../../../website/.vitepress/theme/mermaid-viewer.ts) leaves Markdown, raw page copies, and `llms.txt` generation with their existing owners. +Viewer resources belong to the mounted theme. Route, language, theme, and source-content replacement close the active view; asynchronous Mermaid renders and loaded images receive fresh entries. The body overflow lock relies on the default theme retaining visible overflow on the HTML element. It avoids mutating HTML attributes, which the Mermaid plugin observes and rerenders in response. The [shared modal](../../../../website/.vitepress/theme/media-viewer.ts), [Mermaid adapter](../../../../website/.vitepress/theme/mermaid-viewer.ts), and [image adapter](../../../../website/.vitepress/theme/image-viewer.ts) leave Markdown, raw page copies, and `llms.txt` generation with their existing owners. ## Alternatives considered @@ -26,8 +28,8 @@ Viewer resources belong to the mounted theme. Route, language, theme, and source ## Consequences -The website gains Panzoom as a direct dependency and uses native dialog, shadow-root, and resize-observer support. Viewer zoom and position are transient: resizing refits the diagram, and navigating or changing the theme closes it. Existing page diagrams remain the reading and link-navigation source. +The website gains Panzoom as a direct dependency and uses native dialog, shadow-root, and resize-observer support. Viewer zoom and position are transient: resizing refits the content, and navigating or changing the theme closes it. Existing page images and diagrams remain the reading and link-navigation source. -The [focused tests](../../../../website/tests/mermaid-viewer.spec.ts) run in the root unit-test suite; `docs:check` and `doc-sync` also select them so documentation-only validation exercises the viewer. They cover late rendering, accessible titles, initial and resized fit, control wiring, keyboard cycling, replacement, and resource release. +The [Mermaid tests](../../../../website/tests/mermaid-viewer.spec.ts) and [image tests](../../../../website/tests/image-viewer.spec.ts) run in the root unit-test suite; `docs:check` and `doc-sync` also select them so documentation-only validation exercises the viewer. They cover late rendering and loading, accessible names, initial and resized fit, the 100% action, keyboard cycling, switching between images and diagrams, source replacement, and resource release. **CI coverage gap.** The DOM tests mock Panzoom and do not execute browser layout. Native modal behavior, SVG markers, pointer-anchored wheel zoom, canvas dragging, theme colors, and narrow-screen geometry require real-browser verification. The recorded browser demonstration supplies evidence for the current implementation, but it is not an automated regression check. diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md index b66f427a48..665ab65e48 100644 --- a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 文档站 Mermaid 查看器 +# Agent Note: 文档站图表与图片查看器 Status: implemented @@ -6,15 +6,17 @@ Status: implemented ## 问题 -复杂 Mermaid 图表缩放到文档正文列宽后,细节难以辨认。阅读长时序图需要在保留全图概览的同时放大和平移,以检查交互细节。 +复杂 Mermaid 图表和界面截图缩放到文档正文列宽后,细节难以辨认。阅读时需要在保留全图概览的同时放大和平移,以检查交互细节。 ## 决策 [VitePress 主题](../../../../website/.vitepress/theme/index.ts) 在每张已渲染的 Mermaid SVG 角落增加全屏图标。原生模态对话框使背景不可交互,并支持 Escape 退出。可见标题也用于辅助技术识别对话框;页面标题缺失或为空白时,使用本地化的查看器标题。浮动工具栏集中显示缩放控件、当前比例和适应窗口操作;关闭按钮位于顶部角落,帮助按需展开。键盘焦点在五个按钮之间循环。Panzoom 提供指针、滚轮和双指交互。方向键按固定的屏幕距离平移。关闭时恢复入口焦点且不滚动页面,并恢复页面原有的 overflow 设置。 +普通图片与 Mermaid 共用一个弹层和缩放控制器。已加载、具有非空 alt 文本且独占段落的图片支持点击图片或角落按钮打开;链接图片、行内图片、装饰图片和 `data-no-zoom` 图片保留原有交互。图片的 alt 文本作为弹层标题,比例按钮返回并居中显示自然尺寸(100%),其辅助名称同时包含当前比例;图片视图的键盘焦点在六个按钮之间循环。 + 查看器将 SVG 复制到 shadow root 中。Mermaid 内嵌的选择器和片段 ID 限定在副本内部,因此副本的标记和样式不会解析到原始图表上。Panzoom 变换与视口等大的画布,其中的 SVG 使用 viewBox 的自然尺寸,使指针坐标与变换原点共享同一个中心;初始缩放以及每次窗口尺寸变化都会适配整张图表,且不会放大到超出自然尺寸。这样既保留矢量细节,也使适配不受文档窄列宽度的影响。画布没有可见边框;预留空间使控件不会遮挡适配后的图表。 -查看器资源由已挂载的主题持有。路由、语言、主题和源 SVG 替换都会关闭当前视图;异步渲染的 Mermaid 图表会获得新的入口。body 的 overflow 滚动锁依赖默认主题让 HTML 元素保持 visible overflow。它避免修改 HTML 属性,因为 Mermaid 插件会观察这些属性并重新渲染。[实现](../../../../website/.vitepress/theme/mermaid-viewer.ts) 将 Markdown、原始页面副本和 `llms.txt` 的生成保留在原有归属处。 +查看器资源由已挂载的主题持有。路由、语言、主题和源内容替换都会关闭当前视图;异步渲染的 Mermaid 图表和加载完成的图片会获得新的入口。body 的 overflow 滚动锁依赖默认主题让 HTML 元素保持 visible overflow。它避免修改 HTML 属性,因为 Mermaid 插件会观察这些属性并重新渲染。[共享弹层](../../../../website/.vitepress/theme/media-viewer.ts)、[Mermaid 适配器](../../../../website/.vitepress/theme/mermaid-viewer.ts) 与[图片适配器](../../../../website/.vitepress/theme/image-viewer.ts) 将 Markdown、原始页面副本和 `llms.txt` 的生成保留在原有归属处。 ## 考虑过的替代方案 @@ -26,8 +28,8 @@ Status: implemented ## 影响 -文档站增加了 Panzoom 直接依赖,并使用原生 dialog、shadow root 和 ResizeObserver 支持。查看器的缩放和平移状态是临时的:窗口尺寸变化会重新适配图表,导航或主题变化会关闭视图。原有页面图表仍然是阅读和链接导航的来源。 +文档站增加了 Panzoom 直接依赖,并使用原生 dialog、shadow root 和 ResizeObserver 支持。查看器的缩放和平移状态是临时的:窗口尺寸变化会重新适配内容,导航或主题变化会关闭视图。原有页面图片和图表仍然是阅读和链接导航的来源。 -[定向测试](../../../../website/tests/mermaid-viewer.spec.ts) 属于根单元测试集;`docs:check` 和 `doc-sync`(文档同步门禁)也会选中这些测试,使仅运行文档验证时同样覆盖查看器。它们验证延迟渲染、无障碍标题、初始及窗口变化后的适配、控件连接、键盘焦点循环、图表替换和资源释放。 +[Mermaid 测试](../../../../website/tests/mermaid-viewer.spec.ts) 与[图片测试](../../../../website/tests/image-viewer.spec.ts) 属于根单元测试集;`docs:check` 和 `doc-sync`(文档同步门禁)也会选中这些测试,使仅运行文档验证时同样覆盖查看器。它们验证延迟渲染和加载、无障碍名称、初始及窗口变化后的适配、100% 操作、键盘焦点循环、图片与图表互相切换、源内容替换和资源释放。 **CI 覆盖缺口。** DOM 测试模拟了 Panzoom,不执行浏览器布局。原生模态行为、SVG 标记、以指针为锚点的滚轮缩放、画布拖动、主题颜色和窄屏几何布局需要真实浏览器验证。浏览器演示记录提供了当前实现的证据,但不属于自动回归检查。 diff --git a/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.i18n.yaml b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.i18n.yaml new file mode 100644 index 0000000000..4ab68b411e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md +2026-09-19-windows-acl-mandatory-integrity-confinement.md: f53765f34a7d6d9bcae8b06839c30731a054165d +2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md: a963fd7f549ca2e995f74893c95f1620034664eb diff --git a/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md new file mode 100644 index 0000000000..f53765f34a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md @@ -0,0 +1,56 @@ +# Agent Note: Windows sandbox delete confinement: mandatory integrity plus an ambient-delete deny + +Status: implemented + +English | [中文](2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md) + +## Problem + +The [restricted-token rung](2026-08-08-windows-acl-restricted-token-sandbox.md) confines writes by intersecting the requested access mask with restricting SIDs. That intersection covers only the access check against the object's OWN security descriptor: Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign. `cmd /c del` and `[System.IO.File]::Delete` — anything reaching `DeleteFileW` — therefore deleted files outside the workspace in BOTH confined modes (issue #4581), while plain writes from the same child were denied. + +## Decision + +Every grant now applies three edits in one `SetNamedSecurityInfoW` call, and the restricted token is lowered to match: + +1. the capability-SID allow ACE (`GRANT_MASK`, `OI|CI`) — the write authority, unchanged; +2. a DENY ACE for the world SID on `FILE_DELETE_CHILD` with `CONTAINER_INHERIT_ACE` — the capability ACE's DELETE bit becomes the only delete authority inside a granted root; +3. a Low integrity mandatory label (`S-1-16-4096`, `SYSTEM_MANDATORY_LABEL_NO_WRITE_UP`, `OI|CI`) — and `SetTokenInformation(TokenIntegrityLevel)` lowers the confined token to the same level. + +Each piece is load-bearing. The label is what reaches the parent-`FILE_DELETE_CHILD` route at all: the kernel evaluates the mandatory policy inside the access check whichever right supplied the authority, so a Medium object outside the granted roots is delete-proof as well as write-proof. The deny is what keeps one granted root out of ANOTHER's reach: every granted root carries the Low label, so the integrity check alone would still pass between them. The container-only inheritance on the deny is what keeps the ambient ACL usable: `0x40` is a member of `FILE_ALL_ACCESS`, so a file-level copy would refuse every `GENERIC_ALL`/`FullControl` open inside the root — measured against a real runner before the flag was narrowed. + +The label is STANDING on workspace roots, like the capability ACE and for the same reason: revoking it per session would re-propagate the whole tree on every provision and would race with concurrent sessions. `revokeWrite` clears it only when no other capability grant remains on the directory, so two grants on one directory stay independently revocable. The idempotent skip requires the exact ACE, the exact deny, AND the exact label, so a root granted by an earlier build picks the deny up on its next provision. + +## Alternatives considered + +### Why not make the label revocable per session? + +It removes the externality below, but pays the eager full-tree propagation on every provision (tens of seconds on large workspaces) that the deterministic per-workspace SID exists to avoid, and a concurrent session or a crashed one leaves the label oscillating. + +### Why not Untrusted integrity for read-only mode? + +Measured on this host: an Untrusted (`S-1-16-0`) token cannot start `pwsh` at all — DLL initialization fails (`0x8007045A`, `BCrypt.dll`) — so the read-only mode would have no usable shell. + +### Why not deny `FILE_DELETE_CHILD` with `OI|CI`? + +It also lands the deny on every FILE inside the granted root (observed through `icacls`: `Everyone:(I)(DENY)(DC)`), and `0x40` is part of `FILE_ALL_ACCESS`, so `CreateFileW(GENERIC_ALL)` on those files returns `ERROR_ACCESS_DENIED` for the user, Administrators, SYSTEM, and the DSH host alike. Narrowing the flag removes that class; a FullControl open of a DIRECTORY inside a granted root stays denied, which is the irreducible cost of denying a right that is a member of the full-access mask. + +### Why not drop the confinement layer and document the escape? + +The escape is the report: `cmd /c del` deleting outside the workspace is exactly what the sandbox promises not to allow, and no read-side or ACL-only change closes it — the restricting-SID intersection cannot reach the parent route by construction. + +### Why not give each workspace its own integrity level? + +Mandatory integrity control has five fixed levels (Untrusted, Low, Medium, High, System); they cannot be derived per workspace the way `S-1-4-x-y` capability SIDs are. + +## Consequences + +Bought: deletes confined through every authority Windows accepts AND confined to the granted root that owns them; the previously documented Everyone-write boundary closed; writes, reads, and process visibility otherwise unchanged; fail-closed errors on every Win32 call. Cost, all recorded in the package README: the standing Low label widens the workspace tree for ANY other process running at Low integrity as the same user and outlives DSH (the price of having a write boundary at all at this integrity level); a granted directory must now also grant `WRITE_OWNER` for its label (a Full-control workspace has it, a Modify-only one fails loudly); a FullControl open of a directory inside a granted root is denied; a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low child; FAT-class targets stay unverified. The [restricted-token rung note](2026-08-08-windows-acl-restricted-token-sandbox.md) remains the owner of the rung's token lists, runner contract, and grant lifecycle; this note owns only the delete route and the integrity layer that closes it. + +## Testing + +`runner.spec.ts` pins the behavior against a real runner and a real restricted token: the delete escape is denied in both modes through `cmd`, .NET, `Remove-Item`, and libuv while the file survives on disk; one session cannot delete inside another granted root; NUL stays writable in both modes; a `GENERIC_ALL` open of a file inside a granted root succeeds while the same open of a directory is denied; revoking one of two grants on a directory leaves the surviving grant usable. `acl.spec.ts` pins the real DACL/label lifecycle, that the deny inherits to containers only, and the shared-label revocation rule; `token-failure-paths.spec.ts` pins the exact `TokenIntegrityLevel` payload; the failure-path suites cover every new allocation and early exit, including the label ACL and descriptor releases. + +## Related + +- [Windows sandbox rung: raw ACL restricted tokens](2026-08-08-windows-acl-restricted-token-sandbox.md) — the rung this change extends (kept active; this note supersedes only its delete-route boundary). +- [Sandbox decision](2026-07-06-sandbox.md) — the platform chains and the `partial` enforcement vocabulary. diff --git a/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md new file mode 100644 index 0000000000..a963fd7f54 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md @@ -0,0 +1,56 @@ +# Agent Note: Windows 沙箱删除约束:强制完整性 + 环境性删除拒绝 + +Status: implemented + +[English](2026-09-19-windows-acl-mandatory-integrity-confinement.md) | 中文 + +## 问题 + +[受限令牌档](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)通过把请求的访问掩码与 restricting SIDs 求交集来约束写入。该交集只覆盖针对对象**自身**安全描述符的那次访问检查:Windows 也会依据**父目录**的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署。因此 `cmd /c del` 与 `[System.IO.File]::Delete`——凡走 `DeleteFileW` 的路径——在两种受限模式下都能删除工作区之外的文件(issue #4581),而同一子进程的普通写入却被拒绝。 + +## 决策 + +每次授权现在都在一次 `SetNamedSecurityInfoW` 调用中施加三项编辑,并把受限令牌降到同一级别: + +1. 能力 SID 的允许 ACE(`GRANT_MASK`,`OI|CI`)——写权限本身,未变; +2. 对 world SID 的 `FILE_DELETE_CHILD` 拒绝 ACE,带 `CONTAINER_INHERIT_ACE`——能力 ACE 的 DELETE 位成为授权根内唯一的删除授权来源; +3. Low 完整性强制标签(`S-1-16-4096`、`SYSTEM_MANDATORY_LABEL_NO_WRITE_UP`、`OI|CI`)——并用 `SetTokenInformation(TokenIntegrityLevel)` 把受限令牌降到同一级别。 + +三者都不可省。标签是唯一能触及父目录 `FILE_DELETE_CHILD` 路径的手段:无论权限来自哪一项,内核都在访问检查内部执行强制策略,因此授权根之外的 Medium 对象不仅防写、也防删。拒绝项则负责隔开**各个**授权根:所有授权根都带 Low 标签,仅靠完整性检查它们之间仍会放行。而拒绝项只做容器继承,是为了不破坏环境 ACL 的可用性:`0x40` 是 `FILE_ALL_ACCESS` 的成员,若落到文件上会让授权根内每次 `GENERIC_ALL`/`FullControl` 打开都被拒绝——收窄标志之前已在真实 runner 上实测确认。 + +标签在工作区根上**常驻**,与能力 ACE 同理:按会话回收会导致每次供给都重新传播整棵树,并与并发会话竞争。`revokeWrite` 只在该目录上不再留有其他能力授权时才清除标签,因此同一目录上的两份授权仍可独立撤销。幂等跳过要求精确 ACE、精确拒绝与精确标签三者齐备,因此早前版本已授权的工作区会在下次供给时补上拒绝项。 + +## 考虑过的替代方案 + +### 为什么不让标签按会话回收? + +那样可以消除下面的外部性,但每次供给都要付出确定性工作区 SID 本要避免的急切全树传播(大型工作区上以数十秒计),而且并发会话或崩溃会话会让标签反复消失与重建。 + +### 为什么 read-only 模式不用 Untrusted 完整性? + +本机实测:Untrusted(`S-1-16-0`)令牌根本无法启动 `pwsh`——DLL 初始化失败(`0x8007045A`,`BCrypt.dll`)——该模式将没有可用的 shell。 + +### 为什么不用 `OI|CI` 施加 `FILE_DELETE_CHILD` 拒绝? + +那样拒绝项也会落到授权根内的每个**文件**上(用 `icacls` 观察到的正是 `Everyone:(I)(DENY)(DC)`),而 `0x40` 属于 `FILE_ALL_ACCESS`,于是这些文件上的 `CreateFileW(GENERIC_ALL)` 会对用户、Administrators、SYSTEM 与 DSH host 一并返回 `ERROR_ACCESS_DENIED`。收窄标志消除了这一类影响;授权根内**目录**的 FullControl 打开仍被拒绝,这是拒绝一项属于完全访问掩码的权限所无法避免的代价。 + +### 为什么不干脆去掉这层约束、只把逃逸写进文档? + +逃逸本身就是报告内容:`cmd /c del` 删除工作区之外的文件,恰恰是沙箱承诺不允许的事;而仅靠读侧或纯 ACL 的改动关不掉它——restricting SID 交集在构造上就触及不到父目录路径。 + +### 为什么不让每个工作区拥有自己的完整性级别? + +强制完整性控制只有五个固定级别(Untrusted、Low、Medium、High、System),无法像 `S-1-4-x-y` 能力 SID 那样按工作区派生。 + +## 后果 + +所得:删除在 Windows 接受的每条授权路径上都被约束,且被约束在各自的授权根内;此前记录的 Everyone 写边界被关闭;写入、读取与进程可见性其余部分不变;每次 Win32 调用失败都 fail-closed。所失(全部记录在包 README 中):常驻 Low 标签会向**任何**以同一用户身份运行在 Low 完整性的进程放宽该工作区树,并比 DSH 生命期更长(这是在该完整性级别上拥有写边界本身的代价);被授权目录现在还必须授予 `WRITE_OWNER` 才能写入标签(完全控制的工作区具备,仅授予 Modify 的会大声失败);授权根内目录的 FullControl 打开被拒绝;被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 子进程不可读;FAT 类目标仍未验证。[受限令牌档笔记](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)仍是该档令牌列表、runner 约定与授权生命周期的归属者;本笔记只负责删除路径与封闭它的完整性层。 + +## 测试 + +`runner.spec.ts` 以真实 runner 与真实受限令牌钉住行为:两种模式下经 `cmd`、.NET、`Remove-Item` 与 libuv 的越界删除全部被拒且**宿主侧文件仍存在**;一个会话无法删除另一个授权根内的文件;NUL 在两种模式下仍可写;授权根内**文件**的 `GENERIC_ALL` 打开成功,而**目录**的同一打开被拒绝;同一目录上两份授权之一被撤销后,存留的授权仍可用。`acl.spec.ts` 钉住真实 DACL/标签生命周期、拒绝项只继承到容器、以及共享标签的撤销规则;`token-failure-paths.spec.ts` 钉住 `TokenIntegrityLevel` 的精确载荷;失败路径套件覆盖每处新增分配与提前退出,包括标签 ACL 与描述符的释放。 + +## 相关 + +- [Windows sandbox 档:原始 ACL 受限令牌](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)——本次改动所扩展的档(保持 active;本笔记只取代其删除路径边界)。 +- [沙箱决策](2026-07-06-sandbox.zh.md)——平台链与 `partial` 强制执行词汇。 diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 3964a53291..53a22cb6f9 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -9,10 +9,12 @@ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import * as desktopOffice from './office.ts' import { installDesktopUpdateTaskControl } from './update-tasks.ts' +import { installOfficeEngineResolution } from './office-engine.ts' async function main(): Promise { const runtimeDir = process.argv[2] as string const projectDir = process.argv[3] as string + installOfficeEngineResolution(runtimeDir) const installAnchor = join(runtimeDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json') const profile = loadProfileDirectory('dsh', projectDir, installAnchor) const application = runProfile({ diff --git a/apps/desktop-host/src/office-engine.ts b/apps/desktop-host/src/office-engine.ts new file mode 100644 index 0000000000..d8f0bd1d5f --- /dev/null +++ b/apps/desktop-host/src/office-engine.ts @@ -0,0 +1,44 @@ +/** Resolve packaged Office engine manifests from their complete, unpacked resource directories. */ +import { registerHooks, type ModuleHooks } from 'node:module' +import { realpathSync } from 'node:fs' +import { basename, dirname, join, relative } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +/** + * Locate the archive containing a packaged runtime. + * @param runtimeDir - Prepared or ASAR-contained runtime directory. + * @returns Parent archive path, or undefined for a prepared directory. + */ +export function runtimeArchivePath(runtimeDir: string): string | undefined { + const parent = dirname(runtimeDir) + return basename(parent) === 'app.asar' ? parent : undefined +} + +/** + * Keep engine executable and resource paths usable by native child processes outside Electron. + * Hooks apply only to this thread; worker threads must install their own resolver. + * @param runtimeDir - Prepared or ASAR-contained dsh runtime directory. + * @returns Installed resolver for the Host lifetime, or undefined for a non-ASAR runtime. + */ +export function installOfficeEngineResolution(runtimeDir: string): ModuleHooks | undefined { + if (runtimeArchivePath(runtimeDir) === undefined) return undefined + const root = realpathSync(runtimeDir) + const archive = dirname(root) + const source = pathToFileURL(join(root, 'node_modules', '@deepseek-ai', 'libreoffice-kit-')).href + const destination = pathToFileURL(join(`${archive}.unpacked`, relative(archive, root), 'node_modules', '@deepseek-ai', 'libreoffice-kit-')).href + return registerHooks({ + resolve(specifier, context, nextResolve) { + const resolved = nextResolve(specifier, context) + if (!/^@deepseek-ai\/libreoffice-kit-(?:darwin|win32|linux)-/u.test(specifier)) return resolved + const canonical = pathToFileURL(realpathSync(fileURLToPath(resolved.url))).href + if (!canonical.startsWith(source)) { + if (canonical.startsWith(pathToFileURL(archive + '/').href)) { + throw new Error(`desktop Office engine resolved outside the runtime package directory: ${resolved.url}`) + } + return resolved + } + const physical = realpathSync(fileURLToPath(destination + canonical.slice(source.length))) + return { ...resolved, url: pathToFileURL(physical).href } + }, + }) +} diff --git a/apps/desktop-host/tests/office-engine.spec.ts b/apps/desktop-host/tests/office-engine.spec.ts new file mode 100644 index 0000000000..716aef756d --- /dev/null +++ b/apps/desktop-host/tests/office-engine.spec.ts @@ -0,0 +1,93 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { createRequire, type ModuleHooks } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { installOfficeEngineResolution } from '../src/office-engine.ts' + +const roots: string[] = [] +const hooks: ModuleHooks[] = [] +afterEach(() => { + for (const hook of hooks.splice(0)) hook.deregister() + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function fixture(runtimeName = 'dsh') { + const root = mkdtempSync(join(tmpdir(), 'desktop-office-resolution-')) + roots.push(root) + const runtime = join(root, 'app.asar', runtimeName) + const manifest = 'node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/package.json' + for (const base of [runtime, join(root, 'app.asar.unpacked', runtimeName)]) { + const path = join(base, manifest) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, JSON.stringify({ name: '@deepseek-ai/libreoffice-kit-darwin-arm64', path: realpathSync(dirname(path)) })) + } + const api = join(runtime, 'node_modules/@deepseek-ai/libreoffice-kit/package.json') + mkdirSync(dirname(api), { recursive: true }) + writeFileSync(api, '{"name":"@deepseek-ai/libreoffice-kit"}') + const require: (specifier: string) => unknown = createRequire(join(runtime, 'package.json')) + const hook = installOfficeEngineResolution(runtime)! + hooks.push(hook) + return { root, runtime, manifest, require } +} + +it('resolves engine manifests to physical directories and leaves unrelated modules alone', () => { + const f = fixture() + // Node 24.13 require.resolve bypasses hooks; Electron's require.resolve is covered by packaged Office smoke. + expect(f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) + .toMatchObject({ path: realpathSync(dirname(join(f.root, 'app.asar.unpacked', 'dsh', f.manifest))) }) + expect((f.require('node:fs') as typeof import('node:fs')).realpathSync).toBe(realpathSync) + expect(f.require('@deepseek-ai/libreoffice-kit/package.json')).toEqual({ name: '@deepseek-ai/libreoffice-kit' }) +}) + +it('rejects an engine missing from the unpacked tree instead of using its archived copy', () => { + const f = fixture() + rmSync(join(f.root, 'app.asar.unpacked'), { recursive: true }) + expect(() => { f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json') }).toThrow() +}) + +it('leaves a prepared runtime without an archive unchanged', () => { + const root = mkdtempSync(join(tmpdir(), 'desktop-office-prepared-')) + roots.push(root) + expect(installOfficeEngineResolution(join(root, 'dsh'))).toBeUndefined() +}) + +it('preserves a renamed runtime directory when locating the unpacked engine', () => { + const f = fixture('alternate-runtime') + expect(f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) + .toMatchObject({ path: realpathSync(dirname(join(f.root, 'app.asar.unpacked', 'alternate-runtime', f.manifest))) }) +}) + +it('resolves an engine through a directory alias', () => { + const f = fixture() + const alias = join(f.root, 'alias') + symlinkSync(join(f.root, 'app.asar'), alias, 'junction') + const require: (specifier: string) => unknown = createRequire(join(alias, 'dsh', 'package.json')) + expect(require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) + .toMatchObject({ path: realpathSync(dirname(join(f.root, 'app.asar.unpacked', 'dsh', f.manifest))) }) +}) + +it('rejects an engine resolved elsewhere inside the archive', () => { + const f = fixture() + const other = join(f.root, 'app.asar', 'other', f.manifest) + mkdirSync(dirname(other), { recursive: true }) + writeFileSync(other, '{}') + const require = createRequire(join(f.root, 'app.asar', 'other', 'package.json')) + expect(() => { require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json') }) + .toThrow('outside the runtime package directory') +}) + +it('leaves external engines and the archived WASM engine at their own locations', () => { + const f = fixture() + const external = join(f.root, 'external', f.manifest) + const wasm = join(f.runtime, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/package.json') + for (const path of [external, wasm]) { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, JSON.stringify({ path: realpathSync(dirname(path)) })) + } + const require: (specifier: string) => unknown = createRequire(join(f.root, 'external', 'package.json')) + expect(require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) + .toMatchObject({ path: realpathSync(dirname(external)) }) + expect(f.require('@deepseek-ai/libreoffice-kit-wasm/package.json')) + .toMatchObject({ path: realpathSync(dirname(wasm)) }) +}) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 81f1df65f7..6898608574 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 43cde4f41f2e82bf2d3ccce0a7e93d81234010c5 -README.zh.md: 9e300996cd7ed0428c7199e9f9bd43b6f9077e0a +README.md: e99f771653c62175655f12d6982c04e5f91055d1 +README.zh.md: 9c7f86e5ab1ad0e42fc0fe2ea643c6ca4bed392f diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 43cde4f41f..e99f771653 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -150,9 +150,9 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. -The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. +The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Complete native engine packages, including executables, libraries, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/@deepseek-ai/`. The Desktop Host resolves engine manifests to these physical directories so native subprocesses can read their resources; the JavaScript API and WASM engine remain in ASAR. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). -The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. +The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned Windows builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 9e300996cd..9c7f86e5ab 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -150,9 +150,9 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 -[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 +[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整原生引擎包(包括可执行文件、库、数据、许可证和 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/@deepseek-ai/` 下。Desktop Host 将引擎清单解析到这些物理目录,使原生子进程能读取资源;JavaScript API 和 WASM 引擎保留在 ASAR 中。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 -打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。 +打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和 Windows 未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。 diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index f20d60da48..b26c633d9c 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -4,6 +4,7 @@ import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment. export interface DesktopTargetBuildPaths { readonly root: string readonly artifacts: string + readonly unsignedArtifacts: string readonly runtime: string readonly packageSet: string readonly dsh: string diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 375e61b542..841da2c4ba 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -32,7 +32,7 @@ export function resolveDesktopBuildTarget( /** * Return the mutable preparation and artifact directories owned by one release target. * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name. - * @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. + * @returns {{ root: string, artifacts: string, unsignedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. */ export function desktopTargetBuildPaths(target) { if (!SUPPORTED_TARGETS.has(target)) { @@ -43,6 +43,7 @@ export function desktopTargetBuildPaths(target) { return { root, artifacts: join(root, 'artifacts'), + unsignedArtifacts: join(root, 'unsigned-artifacts'), runtime: join(root, 'runtime'), packageSet: join(root, 'package-set'), dsh: join(root, 'dsh'), diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 3f469c3be2..3ce50573d1 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -63,7 +63,8 @@ export function createElectronBuilderConfig( let primaryRuntimeDestination let dshDestination let windowsCode = [] - const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep/bin/rg'] + const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep/bin/rg', + `**/node_modules/@deepseek-ai/libreoffice-kit-${resolvedPlatform}-${resolvedArch}/**/*`] const windowsSigner = packagesWindows && !unsigned ? createWindowsTokenSigner({ certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE, @@ -90,7 +91,7 @@ export function createElectronBuilderConfig( extraMetadata: { dshDesktopAppId: appId, dshMandatoryUpdatePolicy: policy }, productName: 'DeepSeek Harness', artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}', - directories: { output: unsigned ? join(buildPaths.root, 'unsigned-artifacts') : buildPaths.artifacts }, + directories: { output: unsigned ? buildPaths.unsignedArtifacts : buildPaths.artifacts }, asar: true, electronDist: buildPaths.electron, electronFuses: { runAsNode: true }, diff --git a/apps/desktop/scripts/node-entitlements.plist b/apps/desktop/scripts/jit-entitlements.plist similarity index 100% rename from apps/desktop/scripts/node-entitlements.plist rename to apps/desktop/scripts/jit-entitlements.plist diff --git a/apps/desktop/scripts/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts index 4a180838ee..b5d35f1672 100644 --- a/apps/desktop/scripts/macos-runtime.ts +++ b/apps/desktop/scripts/macos-runtime.ts @@ -40,8 +40,9 @@ export async function signMacOSRuntime( const path = files[next++] if (path === undefined) return const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}` - const entitlements = path === 'dependencies/node/bin/node' - ? join(import.meta.dirname, 'node-entitlements.plist') : undefined + const needsJit = path === 'dependencies/node/bin/node' + || /^node_modules\/@deepseek-ai\/libreoffice-kit-darwin-(?:arm64|x64)\/bin\/libreoffice-kit$/u.test(path) + const entitlements = needsJit ? join(import.meta.dirname, 'jit-entitlements.plist') : undefined const file = join(root, path) const thin = ['cefaedfe', 'cffaedfe', 'feedface', 'feedfacf'].includes(magic(file)) if (cacheDirectory !== undefined && policy !== undefined && thin) { diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 688d3d9671..6909fc4371 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -424,6 +424,7 @@ export async function packageTarget( ...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false', ], electronBuilderEnv) + await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv) await withMacOSNotarizationProxy(mac?.notarizationProxy, () => packageMacOSArtifacts({ arch: target.arch, version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'), @@ -432,13 +433,14 @@ export async function packageTarget( }, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)), undefined, undefined, proxyEvent) } else if (target.platform === 'darwin') { await execute([...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false'], electronBuilderEnv) + await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv) const appPath = join(buildPaths.artifacts, target.arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app') await withMacOSNotarizationProxy(mac?.notarizationProxy, () => notarizeMacOS({ appPath, ...resolveMacOSNotarizationEnvironment(environment) }), undefined, undefined, proxyEvent) } else { await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)) + await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts', ...(invocation.unsigned ? ['--unsigned'] : [])], targetEnv) } - if (signPrimaryRuntime) await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv) if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) if (journal) recordPackagingEvent(journal, { type: 'artifacts', directory: buildPaths.artifacts }) } diff --git a/apps/desktop/scripts/smoke-packaged-runtime.ts b/apps/desktop/scripts/smoke-packaged-runtime.ts index 5fc993f25d..106830b5ad 100644 --- a/apps/desktop/scripts/smoke-packaged-runtime.ts +++ b/apps/desktop/scripts/smoke-packaged-runtime.ts @@ -1,13 +1,23 @@ -/** Validate the assembled Windows application before recording release completion. */ +/** Validate the assembled application, including native Office conversion outside ASAR. */ import { join } from 'node:path' -import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { parseArgs } from 'node:util' +import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { readDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts' import { verifyWindowsCode } from './windows-runtime-signature.mjs' import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' +import { resolveDesktopPackageTarget } from './package-target.ts' const paths = resolveDesktopTargetBuildPaths() -const application = join(paths.artifacts, 'win-unpacked') -const descriptor = await verifyDesktopRuntime(paths.dsh, readDesktopRuntime(paths.dsh).release.version) -await verifyWindowsCode(application) -await smokePreparedRuntime(join(application, 'resources', 'app.asar', 'dsh'), - join(application, 'DeepSeek Harness.exe'), join(application, 'resources', 'runtime'), descriptor) +const { values } = parseArgs({ options: { unsigned: { type: 'boolean', default: false } }, allowPositionals: false }) +const target = resolveDesktopBuildTarget() +const windows = target === 'win-x64' +if (values.unsigned && !windows) throw new Error('desktop smoke: unsigned artifacts require Windows') +const artifacts = values.unsigned ? paths.unsignedArtifacts : paths.artifacts +const application = windows ? join(artifacts, 'win-unpacked') + : join(artifacts, target === 'mac-arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app', 'Contents') +const resources = join(application, windows ? 'resources' : 'Resources') +const executable = windows ? join(application, 'DeepSeek Harness.exe') : join(application, 'MacOS', 'DeepSeek Harness') +const descriptor = await verifyDesktopRuntime(paths.dsh, readDesktopRuntime(paths.dsh).release.version, + resolveDesktopPackageTarget(target)) +if (windows && !values.unsigned) await verifyWindowsCode(application) +await smokePreparedRuntime(join(resources, 'app.asar', 'dsh'), executable, join(resources, 'runtime'), descriptor) diff --git a/apps/desktop/scripts/smoke-prepared-runtime.ts b/apps/desktop/scripts/smoke-prepared-runtime.ts index 94f0c1e55e..697f52708e 100644 --- a/apps/desktop/scripts/smoke-prepared-runtime.ts +++ b/apps/desktop/scripts/smoke-prepared-runtime.ts @@ -4,10 +4,12 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { promisify } from 'node:util' +import { runtimeArchivePath } from '../../desktop-host/src/office-engine.ts' import { desktopNodeEnvironment } from '../src/node-environment.ts' import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' import { smokeDesktopRuntime } from './smoke-runtime.ts' +import { verifyRuntimeArchive } from './verify-runtime-archive.ts' /** * Exercise real runtime files and Host composition, including an ASAR root when packaged. @@ -15,7 +17,7 @@ import { smokeDesktopRuntime } from './smoke-runtime.ts' * @param node Target Electron executable. * @param resourcesRuntime External runtime directory beside the archive. * @param descriptor Runtime descriptor already verified against the selected target, which may differ from the build host. - * @returns Resolves after payload checks, Host readiness and teardown. + * @returns Resolves after archive integrity, payload checks, Host startup, Office conversion and teardown. */ export async function smokePreparedRuntime( root: string, node: string, resourcesRuntime: string, descriptor: DesktopRuntimeDescriptor, @@ -24,6 +26,8 @@ export async function smokePreparedRuntime( const environment = { ...scrubWindowsSigningEnvironment(process.env), NODE_OPTIONS: '', NARB_NATIVE_CACHE_DIR: cache, NARB_DISABLE_NATIVE_CACHE: '0' } try { + const archive = runtimeArchivePath(root) + if (archive !== undefined) await verifyRuntimeArchive(archive, descriptor) const { stdout } = await promisify(execFile)(node, [ '--expose-internals', resolve(import.meta.dirname, '../tests/fixtures/runtime-payload-smoke.mjs'), root, resourcesRuntime, ], { timeout: 120_000, windowsHide: true, diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts index 06768be372..19b26fac1a 100644 --- a/apps/desktop/scripts/smoke-runtime.ts +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -3,17 +3,22 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' +import { readPrimaryRuntime, workspaceDependencyPaths } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts' import { DesktopHostProcess } from '../src/host-process.ts' import { createPluginProfile } from '../src/project-manager.ts' import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' /** - * Prove the final resource tree boots and serves its matching Web frontend. + * Check Host startup, its matching frontend, external plugins and real Office-to-PDF conversion. * @param root - Materialized dsh resources. * @param node - Prepared target Electron executable. * @param runtime - Verified resource descriptor. * @param environment - Credential-scrubbed build environment and private native cache. * @param resourcesRuntime - Bundled interpreters outside the application archive. + * @returns Resolves after checks and teardown; rejects on a check or teardown failure. */ export async function smokeDesktopRuntime( root: string, node: string, runtime: DesktopRuntimeDescriptor, environment: NodeJS.ProcessEnv, resourcesRuntime: string, @@ -29,6 +34,13 @@ export async function smokeDesktopRuntime( const pluginName = 'desktop-runtime-smoke-plugin' const plugin = join(profile, 'node_modules', pluginName) mkdirSync(plugin, { recursive: true }) + const primary = join(resourcesRuntime, 'primary-runtime') + const dependencies = workspaceDependencyPaths(primary, await readPrimaryRuntime(primary)) + await promisify(execFile)(dependencies.python, ['-I', '-B', + fileURLToPath(new URL('../tests/fixtures/office-conversion-inputs.py', import.meta.url)), home], + { env: environment, timeout: 120_000, windowsHide: true }) + const inputs = ['docx', 'xlsx', 'pptx'].map(extension => ({ extension, + bytes: readFileSync(join(home, `input.${extension}`)).toString('base64') })) const cordis = runtime.sharedPackages.find(entry => entry.name === '@deepseek-ai/cordis') if (cordis === undefined) throw new Error('desktop runtime: missing shared Cordis package') writeFileSync(join(plugin, 'package.json'), JSON.stringify({ @@ -37,13 +49,29 @@ export async function smokeDesktopRuntime( })) writeFileSync(join(plugin, 'index.js'), ` import { Context } from '@deepseek-ai/cordis' +import { inspect } from 'node:util' export function apply(ctx) { if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance') ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke', handler(_request, response) { response.end('plugin route ready') } })) + for (const input of ${JSON.stringify(inputs)}) { + ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office/' + input.extension, + async handler(_request, response) { + try { + const bytes = Buffer.from(input.bytes, 'base64') + const result = await ctx.officeToPdf.convert({ extension: input.extension, priority: 'foreground', + source: { key: 'desktop-smoke-' + input.extension, version: 'fixture', bytes: bytes.length, + async read() { return { bytes, version: 'fixture' } } } }) + response.end(Buffer.from(result.pdf)) + } catch (error) { + response.statusCode = 500 + response.end(inspect(error, { depth: 5 })) + } + } })) + } } `) - writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer]\n') + writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf]\n') const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as { dependencies: Record dsh: { profile: { bundles: string[] } } @@ -64,6 +92,18 @@ export function apply(ctx) { } const pluginResponse = await fetch(new URL('/desktop-smoke', ready.url), { headers: { cookie } }) if (await pluginResponse.text() !== 'plugin route ready') throw new Error('desktop runtime: plugin HTTP route failed') + for (const { extension } of inputs) { + const converted = await fetch(new URL(`/desktop-smoke-office/${extension}`, ready.url), { + headers: { cookie }, signal: AbortSignal.timeout(120_000), + }) + if (!converted.ok) throw new Error(`desktop runtime: ${extension} conversion failed: ${await converted.text()}`) + const pdf = Buffer.from(await converted.arrayBuffer()) + if (!/^%PDF-\d\.\d/u.test(pdf.subarray(0, 8).toString()) + || !pdf.subarray(-1024).toString().trimEnd().endsWith('%%EOF')) { + throw new Error(`desktop runtime: invalid ${extension} PDF output`) + } + } + console.log('desktop runtime: DOCX, XLSX, PPTX to PDF passed') } finally { clearTimeout(timer) await host.stop() diff --git a/apps/desktop/scripts/verify-runtime-archive.ts b/apps/desktop/scripts/verify-runtime-archive.ts new file mode 100644 index 0000000000..9ee06a49ed --- /dev/null +++ b/apps/desktop/scripts/verify-runtime-archive.ts @@ -0,0 +1,53 @@ +/** Verify runtime bytes and executable permissions using ASAR records and physical unpacked files. */ +import { createHash } from 'node:crypto' +import { lstat, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { readAsar, type Node } from 'app-builder-lib/out/asar/asar.js' +import type { DesktopRuntimeDescriptor, DesktopRuntimeFile } from '../src/runtime-tree.ts' + +/** + * Compare the complete archived dsh tree with the sealed preparation inventory. + * @param archivePath - Application ASAR file beside its unpacked directory. + * @param expected - Verified preparation descriptor, including its complete file inventory. + * @returns Resolves when bytes, file membership and meaningful executable permissions match. + */ +export async function verifyRuntimeArchive(archivePath: string, expected: DesktopRuntimeDescriptor): Promise { + const archive = await readAsar(archivePath) + const descriptor = await archive.readFile(join('dsh', 'desktop-runtime.json')) + if (!descriptor.equals(Buffer.from(`${JSON.stringify(expected, undefined, 2)}\n`))) { + throw new Error('desktop runtime: archived descriptor differs from preparation') + } + const files: DesktopRuntimeFile[] = [] + const unpacked = new Set() + async function visit(node: Node, path: string): Promise { + if (node.link !== undefined) throw new Error(`desktop runtime: unexpected ASAR link ${path}`) + if (node.files !== undefined) { + for (const [name, child] of Object.entries(node.files)) await visit(child, path === '' ? name : `${path}/${name}`) + return + } + const name = join('dsh', ...path.split('/')) + const physical = node.unpacked === true ? await lstat(join(`${archivePath}.unpacked`, name)) : undefined + if (physical !== undefined && !physical.isFile()) throw new Error(`desktop runtime: unexpected unpacked entry ${path}`) + if (node.unpacked === true) unpacked.add(join(`${archivePath}.unpacked`, name)) + if (path === 'desktop-runtime.json') return + const body = await archive.readFile(name) + // ASAR stores only owner-execute; group/other-only executable files fail the inventory comparison. + const executable = process.platform !== 'win32' && (physical !== undefined + ? (physical.mode & 0o111) !== 0 + : node.executable === true) + files.push({ path, bytes: body.byteLength, sha256: createHash('sha256').update(body).digest('hex'), executable }) + } + await visit(archive.getFile('dsh', false), '') + const entries = await readdir(join(`${archivePath}.unpacked`, 'dsh'), { recursive: true, withFileTypes: true }) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT' && unpacked.size === 0) return [] + throw error + }) + for (const entry of entries) { + if (entry.isDirectory()) continue + const path = join(entry.parentPath, entry.name) + if (!entry.isFile() || !unpacked.has(path)) throw new Error(`desktop runtime: unexpected unpacked entry ${path}`) + } + files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) + if (JSON.stringify(files) !== JSON.stringify(expected.files)) throw new Error('desktop runtime: ASAR integrity verification failed') +} diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index fdbab81b60..a163885f6d 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -13,6 +13,7 @@ describe('desktop build paths', () => { const mutableKeys = [ 'root', 'artifacts', + 'unsignedArtifacts', 'runtime', 'packageSet', 'dsh', diff --git a/apps/desktop/tests/fixtures/office-conversion-inputs.py b/apps/desktop/tests/fixtures/office-conversion-inputs.py new file mode 100644 index 0000000000..c1b86ac10e --- /dev/null +++ b/apps/desktop/tests/fixtures/office-conversion-inputs.py @@ -0,0 +1,20 @@ +"""Create one-page Office inputs with the packaged authoring runtime.""" +from pathlib import Path +import sys +from docx import Document +from openpyxl import Workbook +from pptx import Presentation +from pptx.util import Inches + +root = Path(sys.argv[1]) +document = Document() +document.add_paragraph("Desktop Office conversion 42") +document.save(root / "input.docx") +workbook = Workbook() +workbook.active.append(["Desktop Office conversion", 42]) +workbook.save(root / "input.xlsx") +workbook.close() +presentation = Presentation() +slide = presentation.slides.add_slide(presentation.slide_layouts[6]) +slide.shapes.add_textbox(Inches(1), Inches(1), Inches(6), Inches(1)).text = "Desktop Office conversion 42" +presentation.save(root / "input.pptx") diff --git a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs index 41f6b4223b..bb015d3dff 100644 --- a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs +++ b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs @@ -16,8 +16,6 @@ assert.equal(process.versions.node, descriptor.release.nodeVersion, 'Run with th assert.equal(process.platform, descriptor.platform) assert.equal(process.arch, descriptor.arch) const resourcesRuntime = process.argv[3] ?? join(dirname(root), 'runtime') -const { verifyDesktopRuntime } = await import('../../lib/types/runtime-tree.js') -await verifyDesktopRuntime(root, descriptor.release.version, { platform: process.platform, arch: process.arch }) const requireRuntime = createRequire(join(root, 'package.json')) const scratch = mkdtempSync(join(tmpdir(), 'dsh-runtime-payload-')) @@ -38,9 +36,11 @@ console.log('desktop-node-script-ok') `) const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => /^(?:systemroot|windir|comspec)$/iu.test(name))) const systemBin = process.platform === 'win32' ? join(process.env.SystemRoot, 'System32') : '/usr/bin:/bin' + // This dependency-free fixture checks script launch, without pnpm's implicit install and update-network check. const output = execFileSync(process.execPath, ['--expose-internals', pnpm, 'run', 'check'], { cwd: scratch, encoding: 'utf8', timeout: 45_000, - env: { ...environment, ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, + env: { ...environment, pnpm_config_verify_deps_before_run: 'false', + ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, PATH: `${bin}${delimiter}${systemBin}`, HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch }, }) assert.match(output, /desktop-node-script-ok/u) diff --git a/apps/desktop/tests/macos-cache-policy.spec.ts b/apps/desktop/tests/macos-cache-policy.spec.ts index 7bf9beaff0..cca57085e2 100644 --- a/apps/desktop/tests/macos-cache-policy.spec.ts +++ b/apps/desktop/tests/macos-cache-policy.spec.ts @@ -51,7 +51,7 @@ it('includes certificate, identifier and entitlement contents in the cache polic const create = macOSCachePolicy('probe') const original = create('com.example.file', identity).policy expect(create('com.other.file', identity).policy).not.toBe(original) - expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/node-entitlements.plist')).policy).not.toBe(original) + expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/jit-entitlements.plist')).policy).not.toBe(original) certificate = 'renewed-certificate' expect(macOSCachePolicy('probe')('com.example.file', identity).policy).not.toBe(original) }) diff --git a/apps/desktop/tests/macos-runtime.spec.ts b/apps/desktop/tests/macos-runtime.spec.ts index 7d4025f530..5ca9908c28 100644 --- a/apps/desktop/tests/macos-runtime.spec.ts +++ b/apps/desktop/tests/macos-runtime.spec.ts @@ -47,14 +47,18 @@ it('awaits other signers before rejecting and permitting output cleanup', async expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'b.node'), identity) }) -it('grants JIT only to the standalone Node interpreter', async () => { +it('grants JIT only to the standalone Node interpreter and native Office helpers', async () => { const path = root() mkdirSync(join(path, 'dependencies/node/bin'), { recursive: true }) const node = join(path, 'dependencies/node/bin/node') const addon = join(path, 'addon.node') - for (const file of [node, addon]) writeFileSync(file, Buffer.from('cffaedfe00000000', 'hex')) + const helpers = ['arm64', 'x64'].map(arch => join(path, 'node_modules/@deepseek-ai', `libreoffice-kit-darwin-${arch}`, 'bin/libreoffice-kit')) + for (const helper of helpers) mkdirSync(join(helper, '..'), { recursive: true }) + for (const file of [node, addon, ...helpers]) writeFileSync(file, Buffer.from('cffaedfe00000000', 'hex')) await signMacOSRuntime(path, 'com.example.app', identity) - expect(signMacOSRuntimeCode).toHaveBeenCalledWith(node, expect.any(String), identity, - join(import.meta.dirname, '../scripts/node-entitlements.plist')) + for (const file of [node, ...helpers]) { + expect(signMacOSRuntimeCode).toHaveBeenCalledWith(file, expect.any(String), identity, + join(import.meta.dirname, '../scripts/jit-entitlements.plist')) + } expect(signMacOSRuntimeCode).toHaveBeenCalledWith(addon, expect.any(String), identity, undefined) }) diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index 176c9c86b5..41e1ee8d4e 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -99,6 +99,35 @@ it.each(['--unsigned', '--prepare-only'])('keeps %s hardware-free and creates no expect(withWindowsSigningStage).not.toHaveBeenCalled() for (const call of run.run.mock.calls) expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN') expect(writeFileSync).not.toHaveBeenCalled() + expect(stages.includes('exec tsx scripts/smoke-packaged-runtime.ts --unsigned')).toBe(mode === '--unsigned') +}) + +it('checks the assembled macOS runtime before notarizing and recording the release', async () => { + const { run, stages } = supervisor() + vi.mocked(packageMacOSArtifacts).mockImplementationOnce(async () => { + expect(stages.at(-1)).toBe('exec tsx scripts/smoke-packaged-runtime.ts') + expect(writeFileSync).not.toHaveBeenCalled() + }) + await packageTarget(parseDesktopPackageInvocation(['mac-arm64'], 'darwin', 'arm64'), environment, run) + expect(packageMacOSArtifacts).toHaveBeenCalledOnce() + expect(writeFileSync).toHaveBeenCalledOnce() +}) + +it.each([false, true])('refuses macOS notarization and release records after an assembled-runtime failure (directory=%s)', async (directory) => { + const { run } = supervisor('exec tsx scripts/smoke-packaged-runtime.ts') + await expect(packageTarget(parseDesktopPackageInvocation(['mac-arm64', ...(directory ? ['--dir'] : [])], 'darwin', 'arm64'), environment, run)) + .rejects.toThrow('stage refused') + expect(withMacOSNotarizationProxy).not.toHaveBeenCalled() + expect(packageMacOSArtifacts).not.toHaveBeenCalled() + expect(writeFileSync).not.toHaveBeenCalled() +}) + +it('checks macOS directory packages without writing a release record', async () => { + const { run, stages } = supervisor() + await packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--dir'], 'darwin', 'arm64'), { ...environment, APPLE_KEYCHAIN_PROFILE: 'fixture' }, run) + expect(stages.at(-1)).toBe('exec tsx scripts/smoke-packaged-runtime.ts') + expect(packageMacOSArtifacts).not.toHaveBeenCalled() + expect(writeFileSync).not.toHaveBeenCalled() }) it.each([undefined, '2'])('passes macOS pack concurrency %s only to workspace packing and download routing only to download stages', async (concurrency) => { diff --git a/apps/desktop/tests/windows-asar-unpack.spec.ts b/apps/desktop/tests/windows-asar-unpack.spec.ts index 2d5c79563b..5cbe410105 100644 --- a/apps/desktop/tests/windows-asar-unpack.spec.ts +++ b/apps/desktop/tests/windows-asar-unpack.spec.ts @@ -1,4 +1,4 @@ -import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -10,6 +10,9 @@ import { readAsar } from 'app-builder-lib/out/asar/asar.js' import { afterEach, expect, it, vi } from 'vitest' import { prepareWindowsAsarUnpack, verifyWindowsAsarUnpack } from '../scripts/windows-asar-unpack.mjs' import { createElectronBuilderConfig } from '../scripts/electron-builder-config.mjs' +import { verifyRuntimeArchive } from '../scripts/verify-runtime-archive.ts' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { inventoryDesktopRuntime, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' vi.mock('../scripts/windows-sign.mjs', async importOriginal => ({ ...await importOriginal(), @@ -184,3 +187,113 @@ it.each([true, false])('validates the real builder hook for unsigned=%s', async await expect(config.afterSign(input.context)).rejects.toThrow('PE bytes changed') } }) + +it.each([false, true])('keeps the complete Office engine outside ASAR with external source=%s', async (external) => { + const input = await fixture(external) + const engine = join('node_modules', '@deepseek-ai', 'libreoffice-kit-win32-x64') + const files = ['package.json', 'prebuilds.json', 'bin/libreoffice-kit', 'program/registry/main.xcd'] + for (const file of files) { + const path = join(input.source, engine, file) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, '{}') + } + const wasm = join(input.source, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/package.json') + await mkdir(dirname(wasm), { recursive: true }) + await writeFile(wasm, '{}') + const config = createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: 'com.example.office', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', + DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }), + DSH_DESKTOP_UNSIGNED: '1', + }, 'win32', 'x64', input.source) + input.config.asarUnpack = [...config.asarUnpack] + await config.beforePack(input.context) + await packageFixture(input) + const archive = await readAsar(join(input.resources, 'app.asar')) + expect(archive.getFile(join('dsh', 'node_modules', '@deepseek-ai', 'libreoffice-kit-wasm', 'package.json')).unpacked).not.toBe(true) + for (const file of files) { + expect(archive.getFile(join('dsh', engine, file), false).unpacked).toBe(true) + expect(await readFile(join(input.resources, 'app.asar.unpacked', 'dsh', engine, file), 'utf8')).toBe('{}') + } +}) + +async function seal(input: Awaited>): Promise { + const descriptor: DesktopRuntimeDescriptor = { + schemaVersion: 1, + release: { schemaVersion: 1, version: '1.0.0', hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: process.versions.node, pnpmVersion: '11.7.0' }, + platform: process.platform, arch: process.arch, + sharedPackages: [{ name: 'foo', version: '1.0.0', path: 'node_modules/foo' }], + files: inventoryDesktopRuntime(input.source), + } + await writeFile(join(input.source, 'desktop-runtime.json'), `${JSON.stringify(descriptor, undefined, 2)}\n`) + return descriptor +} + +it('verifies archived bytes against the preparation descriptor', async () => { + const input = await fixture(false) + const expected = await seal(input) + await packageFixture(input) + const archive = join(input.resources, 'app.asar') + await expect(verifyRuntimeArchive(archive, expected)).resolves.toBeUndefined() + await writeFile(join(input.resources, 'app.asar.unpacked/dsh/node_modules/foo/native.node'), 'tampered') + await expect(verifyRuntimeArchive(archive, expected)).rejects.toThrow('ASAR integrity') +}) + +it.each(['sharedPackages', 'release', 'files'] as const)('rejects changed archived descriptor %s', async (field) => { + const input = await fixture(false) + const expected = await seal(input) + const changed = { ...expected, [field]: field === 'release' ? { ...expected.release, version: '2.0.0' } : [] } + await writeFile(join(input.source, 'desktop-runtime.json'), `${JSON.stringify(changed, undefined, 2)}\n`) + await packageFixture(input) + await expect(verifyRuntimeArchive(join(input.resources, 'app.asar'), expected)).rejects.toThrow('archived descriptor differs') +}) + +it.each(['missing', 'extra'] as const)('rejects %s archived files', async (state) => { + const input = await fixture(false) + const expected = await seal(input) + if (state === 'missing') await rm(join(input.source, 'node_modules/foo/companion.json')) + else await writeFile(join(input.source, 'extra.json'), '{}') + await packageFixture(input) + await expect(verifyRuntimeArchive(join(input.resources, 'app.asar'), expected)).rejects.toThrow('ASAR integrity') +}) + +it.each(['directory', 'extra'] as const)('rejects an unpacked %s without a matching file record', async (state) => { + const input = await fixture(false) + const expected = await seal(input) + await packageFixture(input) + const root = join(input.resources, 'app.asar.unpacked/dsh') + if (state === 'directory') { + const file = join(root, 'node_modules/foo/native.node') + await rm(file) + await mkdir(file) + } else await writeFile(join(root, 'extra.json'), '{}') + await expect(verifyRuntimeArchive(join(input.resources, 'app.asar'), expected)).rejects.toThrow('unexpected unpacked entry') +}) + +// Windows file symlinks require privileges unavailable on some CI runners. +it.skipIf(process.platform === 'win32')('rejects ASAR links', async () => { + const input = await fixture(false) + const expected = await seal(input) + await symlink('companion.json', join(input.source, 'node_modules/foo/alias.json')) + await packageFixture(input) + await expect(verifyRuntimeArchive(join(input.resources, 'app.asar'), expected)).rejects.toThrow('unexpected ASAR link') +}) + +it.skipIf(process.platform === 'win32')('checks packed executable records and physical unpacked permissions', async () => { + const input = await fixture(false) + for (const name of ['extensionless', 'native.node']) await chmod(join(input.source, 'node_modules/foo', name), 0o755) + const expected = await seal(input) + await packageFixture(input) + const archive = join(input.resources, 'app.asar') + await expect(verifyRuntimeArchive(archive, expected)).resolves.toBeUndefined() + await chmod(join(input.resources, 'app.asar.unpacked/dsh/node_modules/foo/native.node'), 0o644) + await expect(verifyRuntimeArchive(archive, expected)).rejects.toThrow('ASAR integrity') +}) + +it.skipIf(process.platform === 'win32')('rejects group-only executable files whose ASAR record loses that permission', async () => { + const input = await fixture(false) + await chmod(join(input.source, 'node_modules/foo/extensionless'), 0o654) + const expected = await seal(input) + await packageFixture(input) + await expect(verifyRuntimeArchive(join(input.resources, 'app.asar'), expected)).rejects.toThrow('ASAR integrity') +}) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index d7d15d6647..cb2f2c1d4d 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 145b93091180d8a1330911f978dbfc77703e1ad1 -config-catalog.zh.md: c31310338f5a17b626900ef7939a3f85a84954f7 +config-catalog.md: fa99628cbe6b1c5d7b46a5b142da1e8ead419c0e +config-catalog.zh.md: c6177c03218f0999ec323f31da8feb752ecd2d14 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 145b930911..fa99628cbe 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2122,7 +2122,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:44`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c31310338f..c6177c0321 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2126,7 +2126,7 @@ export interface Config { } ``` -来源:[`packages/sandbox/sandbox-local/src/index.ts:44`](../packages/sandbox/sandbox-local/src/index.ts) +来源:[`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index 7bb8ec7c19..c8eeb0c0bb 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/subsystems/sandbox.md -sandbox.md: c29450d83c54d48a69f74f0b2fa38fe7c4f35ef2 -sandbox.zh.md: 71b55fcb8d46144e45daef717999d8905a78eec4 +# pnpm run verify-translation-pairing --write docs//subsystems//sandbox.md +sandbox.md: 05be79018f60e1e6af964449756322d283048c0d +sandbox.zh.md: 843be48f65ebe47b6c3307f5914ea011191e0bcb diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index c29450d83c..05be79018f 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -27,7 +27,7 @@ Only the first two modes can be sent to a provider. A `danger-full-access` consu type ConfinedSandboxMode = Exclude ``` -Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. Older Landlock ABIs and the Windows ACL runner's Everyone/hard-link boundaries are current partial cases. +Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. Older Landlock ABIs and the Windows ACL runner's hard-link, unconfined-read, and AppContainer-ACL boundaries are current partial cases. ```ts type-equiv /** diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 71b55fcb8d..843be48f65 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -27,7 +27,7 @@ type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' type ConfinedSandboxMode = Exclude ``` -强制执行完整性是后端报告的事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。当前的部分强制执行情形包括较旧的 Landlock ABI,以及 Windows ACL runner 的 Everyone 与硬链接边界。 +强制执行完整性是后端报告的事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。当前的部分强制执行情形包括较旧的 Landlock ABI,以及 Windows ACL runner 的硬链接、读取不受限与 AppContainer ACL 边界。 ```ts type-equiv /** diff --git a/package.json b/package.json index 827b4000dd..9a5c8329d3 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments", "docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments", "docs:preview": "pnpm --filter @deepseek-ai/website run preview", - "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts website/tests/code-groups.spec.ts website/tests/page-markdown-actions.spec.ts website/tests/raw-markdown.spec.ts && pnpm run docs:build", + "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts website/tests/image-viewer.spec.ts website/tests/code-groups.spec.ts website/tests/page-markdown-actions.spec.ts website/tests/raw-markdown.spec.ts && pnpm run docs:build", "website:dev": "pnpm run docs:dev", "website:build": "pnpm run docs:build", "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 30cfbd2154..13441f23d7 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-local/README.md -README.md: 15da8baaa3ab7ce476f5c45c6207657915fd7815 -README.zh.md: 0e55553e1ff18ae6e91563c34eb30f4dab61f45c +# pnpm run verify-translation-pairing --write packages//sandbox//sandbox-local//README.md +README.md: 278f2e5810e0dee12b061b33d08716cdcf5b7ae2 +README.zh.md: a7854c30d03d6be84bc0dc5b2a7a4bd1e07f97e4 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 15da8baaa3..278f2e5810 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -50,7 +50,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Confined execution and enforcement -With the provider mounted, a command runs under the mode you resolve per call. Enforcement is a reported fact, not a promise: `full` means the backend governs every promised file effect, while `partial` means it governs only a subset — the Windows ACL rung (Everyone and hard-link boundaries) and older Landlock ABIs are the current partial cases, so a consumer that requires an absolute boundary can reject or surface them. Denied file effects surface through the backend's denial dialect, and a runner that fails before executing the command reports a structured runner-failure signature. +With the provider mounted, a command runs under the mode you resolve per call. Enforcement is a reported fact, not a promise: `full` means the backend governs every promised file effect, while `partial` means it governs only a subset — the Windows ACL rung (hard-link, unconfined-read, and AppContainer-ACL boundaries) and older Landlock ABIs are the current partial cases, so a consumer that requires an absolute boundary can reject or surface them. Denied file effects surface through the backend's denial dialect, and a runner that fails before executing the command reports a structured runner-failure signature. ### Failures and recovery @@ -78,7 +78,7 @@ The `@deepseek-ai/node-addon-system/landlock-run` API supplies the platform laun The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists derived from the shared `writableRoots` helper, so exactly the mode's promised file effects are governed; every root is canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). -The Windows rung keeps one deterministic write SID and standing ACE per workspace, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because the restricted token must retain Everyone and NTFS hard links alias one file object across paths. +The Windows rung keeps one deterministic write SID and standing ACE per workspace, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. When the built ACL runner is absent, source launch pins the `tsx/esm/api` loader and TypeScript path mapping to this installation. The command's working directory and ambient `TSX_TSCONFIG_PATH` cannot select the runner's source dependencies. @@ -127,7 +127,7 @@ No direct invalidation; the named consumers own any request-prefix changes. These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog. -- **Windows ACL enforcement is partial** — the restricted token must retain Everyone for process initialization, so external objects granting Everyone write access remain writable; NTFS hard links also alias one file object across workspace and external paths. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full. +- **Windows ACL enforcement is partial** — NTFS hard links alias one file object across workspace and external paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full. - **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full. - **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it. - **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 0e55553e1f..a7854c30d0 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -50,7 +50,7 @@ kind: "package-reference" ### 受限执行与强制执行 -挂载提供方后,命令在你逐调用解析的模式下运行。强制执行是报告的事实,而非承诺:`full` 表示后端管辖模式承诺的每个文件操作,`partial` 表示它只管辖子集——Windows ACL 档(Everyone 与硬链接边界)与较旧的 Landlock ABI 是当前的部分强制执行情形,因此需要绝对边界的消费方可以拒绝或向上暴露它们。被拒绝的文件操作通过后端的拒绝方言呈现,执行命令前失败的 runner 会报告结构化的 runner 失败签名。 +挂载提供方后,命令在你逐调用解析的模式下运行。强制执行是报告的事实,而非承诺:`full` 表示后端管辖模式承诺的每个文件操作,`partial` 表示它只管辖子集——Windows ACL 档(硬链接、读取不受限与 AppContainer ACL 边界)与较旧的 Landlock ABI 是当前的部分强制执行情形,因此需要绝对边界的消费方可以拒绝或向上暴露它们。被拒绝的文件操作通过后端的拒绝方言呈现,执行命令前失败的 runner 会报告结构化的 runner 失败签名。 ### 失败与恢复 @@ -78,7 +78,7 @@ bwrap profile 组合只读宿主根目录、全新 `/dev` 与私有 PID 命名 Seatbelt profile 默认允许,带 `(deny file-write*)` 与来自共享 `writableRoots` 辅助函数的写入 allow-list,因此恰好管辖模式承诺的文件操作;每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。 -Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为受限令牌必须保留 Everyone,且 NTFS 硬链接会把同一文件对象别名为多个路径。 +Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。 构建后的 ACL runner 缺失时,源码启动将 `tsx/esm/api` 加载器和 TypeScript 路径映射固定到本安装目录。命令的工作目录和环境中的 `TSX_TSCONFIG_PATH` 无法选择 runner 的源码依赖。 @@ -127,7 +127,7 @@ Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同 这些限制说明提供方何时不合适,或何时需要特别运维。它们是当前包约束,不是通用平台对比或任务积压。 -- **Windows ACL 只能实现部分强制执行**——受限令牌必须保留 Everyone 以完成进程初始化,因此授予 Everyone 写访问的外部对象仍可写;NTFS 硬链接也会使工作区路径与外部路径指向同一个文件对象。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。 +- **Windows ACL 只能实现部分强制执行**——NTFS 硬链接会使工作区路径与外部路径指向同一个文件对象,读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。 - **Landlock 可能只实现部分强制执行**——较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。 - **Seatbelt 依赖已弃用的 `sandbox-exec`**——macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。 - **runner 选择在提供方生命周期内缓存**——安装、移除或修复 runner 后,必须重载插件才能改变选择。 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 037dc90079..5f180668cf 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -15,8 +15,9 @@ * session); the private-temp ACEs are revoked on dispose. The runner * receives both SIDs (their presence marks the seam-managed contract) and * stops managing DACLs itself. The rung reports partial enforcement because - * WRITE_RESTRICTED must retain Everyone in its - * restricting list and NTFS hard links alias one file object across paths. + * NTFS hard links alias one file object across paths, reads stay unconfined, + * and a tree another AppContainer tool has ACL'd with a package SID is not + * readable by the Low-integrity child. * @module @deepseek-ai/dsh-sandbox-local */ @@ -178,11 +179,12 @@ const STATIC_ENFORCEMENT: Record = bwrap: 'full', landlock: 'full', seatbelt: 'full', - // WRITE_RESTRICTED needs Everyone in both restricting lists for process - // initialization. An external object that grants Everyone write access - // therefore remains writable, and NTFS hard links can alias a granted - // workspace file to a path outside it. The backend enforces the remaining - // ACL-addressable surface but must not advertise the absolute promise. + // Everyone stays in both restricting lists for process initialization, but + // the Low label denies the write authority it used to confer. NTFS hard + // links still alias a granted workspace file to a path outside it, reads + // stay unconfined, and an AppContainer-ACL'd tree is unreadable to the + // child: the backend enforces the remaining ACL-addressable surface but + // must not advertise the absolute promise. 'windows-acl': 'partial', } @@ -518,7 +520,8 @@ export class LocalSandboxProvider extends SandboxProvider { // every promised file effect by construction, so their passing probes // are always full enforcement; the Landlock launcher's probe report // distinguishes full from per-ABI-partial, while windows-acl is always - // partial for its documented Everyone and hard-link boundaries. + // partial for its documented hard-link, unconfined-read, and + // AppContainer-ACL boundaries. switch (runner) { case 'bwrap': { const probe = this.internals.probeBwrap ?? (() => defaultProbeBwrap(this.probeTimeoutMs)) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index ed39d664a1..0e8cc8e2b6 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 30eaea27b9f8a0ebd1691348c9e6f87706891866 -README.zh.md: eeb65e5db1044eb75e0c13c5f3c76667432dc485 +# pnpm run verify-translation-pairing --write packages//sandbox//sandbox-windows-acl//README.md +README.md: 42b2290028c9138e38594ae23ce94fb3f0ebaa85 +README.zh.md: 6a99eec14adf65027f199079f7ae08d94b0636ad diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 30eaea27b9..42b2290028 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -On Windows, this package confines child-process writes to the workspace and a private temporary directory. `workspace-write` grants both locations, while `read-only` grants neither. Mounting `dsh-sandbox-local` selects this behavior automatically for confined bash and PowerShell commands, or callers can use the public `AclSandbox` API directly with captured standard streams. Any failed Win32 operation prevents the child from starting unrestricted. The guarantee is intentionally partial because process startup retains Everyone access and NTFS hard links can expose the same file through another path; callers can detect this limitation through the reported `partial` enforcement level. +On Windows, this package confines child-process writes and deletes to the workspace and a private temporary directory: `workspace-write` grants both, `read-only` grants neither. Mounting `dsh-sandbox-local` selects this for confined bash and PowerShell commands, or callers use the public `AclSandbox` API directly; any failed Win32 operation prevents an unrestricted spawn. Each grant combines a capability-SID allow ACE, a deny of the ambient parent-directory delete right, and a Low integrity label the lowered token must match, so one granted root cannot reach another. The guarantee stays partial: hard links alias file objects and files ACL'd by another AppContainer tool stay unreadable. ## Table of Contents @@ -59,15 +59,15 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure +sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair rmSync(tempDir, { recursive: true, force: true }) ``` -The workspace ACEs are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SID. +The workspace security descriptor edits are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. Each grant is one call carrying the capability-SID allow ACE, the ambient-delete deny, and the Low no-write-up label. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SIDs. ### What confinement gives you -Under `workspace-write`, the child may write into the workspace and its private temp directory; other ACL-addressable writes are denied except the documented Everyone and hard-link boundaries. Under `read-only`, no explicit write grants exist, so writes are denied with the same documented ambient boundaries. +Under `workspace-write`, the child may write into the workspace and its private temp directory; other ACL-addressable writes and deletes are denied except the documented hard-link and AppContainer-ACL boundaries. Under `read-only`, no explicit write grants exist, so writes and deletes are denied with the same documented boundaries. Temp isolation is per live session/workspace pair: sessions sharing a workspace share its write authority but cannot write one another's temp directories. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. @@ -87,11 +87,11 @@ This section explains the restricted-token mechanism, the token lists, the runne ### Mechanism -The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine and every later session, call, or restart hits the exact-ACE skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction. +The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root security descriptor edit materializes once per workspace per machine and every later session, call, or restart hits the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction. ### Modes and token lists -`workspace-write` (logon SID, Everyone, workspace SID, temp SID) grants the workspace and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing ACE keeps a re-upgrade free of re-propagation. +`workspace-write` (logon SID, Everyone, workspace SID, temp SID) grants the workspace and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation. NUL writes are ambient, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in both modes. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, not the device DACL), while PowerShell's `> $null` redirection keeps working. @@ -111,15 +111,18 @@ When launched with the subprocess control marker, the runner forwards fd 7 throu ### Verified boundaries -- **Everyone grants remain ambient write authority** — Everyone must stay in both restricting lists (removing it breaks early DLL initialization and CNG); an external NTFS object whose DACL grants Everyone a requested write right clears both checks and stays writable under both modes. -- **Hard links are file-object aliases, not path aliases** — an inheritable workspace ACE propagated onto an existing hard link changes the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations. -- **Writes are restricted; reads, network, and process visibility are not** — `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets; `read-only` therefore needs a read-side policy to be expressed. +- **Everyone stays in both restricting lists, but no longer confers write authority** — the keep-alive group is required for early DLL initialization and CNG; the Low label now denies an Everyone-granted write outside the labeled roots, so that former gap is closed. +- **Inside a granted root, the capability ACE's DELETE bit is the only delete authority** — the grant denies `FILE_DELETE_CHILD` to the world SID, which also removes the ambient default: a file whose own DACL grants no DELETE is no longer deletable through its parent's rights, by the confined child or by the user's own processes. The user's ordinary deletes keep working because the workspace DACL grants them DELETE directly. +- **The deny inherits to subdirectories only, and a FullControl open of one is denied** — `FILE_DELETE_CHILD` is evaluated on directories, so the deny carries `CONTAINER_INHERIT_ACE` and never reaches files (its bit, `0x40`, is part of `FILE_ALL_ACCESS`, so a file-level copy would refuse every `GENERIC_ALL`/`FullControl` open by the user, Administrators, SYSTEM, or the DSH host). Directories inside a granted root keep the deny and therefore refuse those opens; `DELETE`-based deletes, `MAXIMUM_ALLOWED`, and ordinary read/write opens are unaffected, and both outcomes are pinned by the runner suite. +- **Writes and deletes are restricted; reads, network, and process visibility are not** — neither layer intersects reads, so a confined child can read any caller-readable file (including files in another workspace) and open sockets; `read-only` therefore needs a read-side policy to be expressed. +- **Hard links are file-object aliases, not path aliases** — an inheritable workspace grant propagated onto an existing hard link labels and grants the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations. - **Console isolation is unavailable** — children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`); children share the host console, and pipe-based stdio redirection is unaffected. -- **ACL grants are standing directory mutations** — workspace ACEs stand by design (the reuse cache, never revoked); temp ACEs are revoked by `dispose()`; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. -- **Granted directories must be caller-owned** — the owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. +- **Security descriptor edits are standing directory mutations** — workspace ACEs, denies, and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`, except that a revoke keeps the shared label while another capability grant remains on the directory, and the deny it leaves behind disappears with the temp directory itself; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. +- **A standing Low label outlives DSH and widens the tree for other Low-integrity processes** — the workspace's inheritable label survives the session (and same-volume moves), so any other process running at Low integrity as the same user — another product's Low-IL sandbox, a Protected Mode reader — can write and delete inside the workspace, where a Medium label would have denied it. The label is the price of the write boundary: without it the confined child cannot write at all, and revoking it per session would re-propagate the whole tree on every provision. +- **Granted directories must be caller-owned and grant `WRITE_OWNER`** — the owner's implicit rights cover only `READ_CONTROL` and `WRITE_DAC`; the label lives in the SACL, so the combined apply additionally needs `WRITE_OWNER` (a Full-control directory, the normal workspace case, has it). A directory whose DACL grants only Modify now fails the grant loudly instead of silently skipping the confinement. - **The ambient temp root is never granted implicitly** — direct callers must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or disable temp writes with `tempDir: null`; the actual temp directory must be disjoint from every writable root. - **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing the same workspace SID cannot write one another's temp directories. -- **`whoami` and token-inspection cmdlets fail under the restricted token** — `GetTokenInformation` on the duplicate is partially unavailable to the child, which is diagnostic noise rather than an operational failure. +- **`whoami` and token-inspection cmdlets may fail under the restricted token** — `GetTokenInformation` on the duplicate is partially unavailable to the child, so whether a reporting cmdlet works is host-dependent; it is diagnostic noise rather than an operational failure. ### Header verification and source map @@ -127,10 +130,10 @@ The sandbox-owned SID, ACL, token, file, and lock declarations are checked again | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | `AclSandbox`: restricted-token policy, DACL grants, fail-closed spawn and dispose | +| [`src/index.ts`](src/index.ts) | `AclSandbox`: restricted-token policy, DACL and label grants, fail-closed spawn and dispose | | [`src/runner.ts`](src/runner.ts) | The runner entry over shared Win32 process primitives | | [`src/grant.ts`](src/grant.ts) | `AclWriteGrant`: server-side grant materialization and revocation | -| [`src/token.ts`](src/token.ts) + [`src/acl.ts`](src/acl.ts) | Win32 token and DACL primitives behind the sandbox | +| [`src/token.ts`](src/token.ts) + [`src/acl.ts`](src/acl.ts) | Win32 token, DACL, and mandatory-label primitives behind the sandbox | @@ -173,7 +176,8 @@ These limits define when the backend is a poor fit or needs special operational - **Piped stdio capture is impossible for confined grandchildren** — libuv's pipe stdio uses named pipes, whose client-end open requests write access no restricting SID is granted (the Win32 layer's default SD template, not the token default DACL), so `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM; inherited and ignored stdio spawns work, and anonymous pipes (PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE. - **Grant materialization is an eager full-tree propagation** — `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (tens of seconds on large workspace trees); the per-workspace identity pays it once per workspace per machine, and the exact-ACE skip makes every later provision cheap. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. -- **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable** — the UI-side warnings are not implemented, a FAT volume as a grant root fails loudly, and a FAT-class target outside the granted roots has no security descriptors so it stays writable under both confined modes; FAT is treated as legacy residue. +- **Reads stop at objects another AppContainer-based tool has ACL'd with a package SID** — a file whose DACL carries an ACE for a package SID (`S-1-15-2-…`) is inaccessible to a Low-integrity token on this host, even when the same DACL grants the user full control and Everyone read (observed: adding that single ACE to a fresh file reproduces the denial, granting Everyone read does not lift it, and the same bytes copied elsewhere stay readable). The kernel rule behind it is unconfirmed and not this package's to change; tools that sandbox themselves with AppContainers stamp such ACEs, so a tree they touched becomes unreadable to this backend's child. Removing the foreign ACE (or re-installing the affected tree) restores access. +- **Wide-directory and FAT-volume warnings are deferred; the FAT-class residue is unverified** — the UI-side warnings are not implemented, a FAT volume as a grant root fails loudly, and a FAT-class target outside the granted roots stores no security descriptor; its effective integrity label is assigned by the system rather than recorded on the object, so the label layer's behavior there is untested. FAT stays legacy residue. - **PowerShell language mode differs by confined mode** — under `read-only`, PowerShell cannot create its AppLocker probe files in temp and conservatively starts in ConstrainedLanguage (`Add-Type`, non-core .NET static calls, COM, and reflection fail); the shipped `workspace-write` path lets the probe complete, so pwsh stays in FullLanguage unless host-wide WDAC/AppLocker policy says otherwise, while a direct `AclSandbox` with `tempDir: null` has no such guarantee. This split is PowerShell startup behavior, not part of the ACL write boundary. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index eeb65e5db1..6a99eec14a 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -在 Windows 上,本包将子进程的写入限制在工作区和私有临时目录内。`workspace-write` 授予对这两个位置的写入权限,`read-only` 则均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 和 PowerShell 命令会自动获得此行为;调用方也可以直接使用公开 `AclSandbox` API,并捕获标准流。任何 Win32 操作失败都会阻止子进程在不受限制的情况下启动。该保证特意标记为部分强制,因为进程启动会保留 Everyone 访问权限,NTFS 硬链接也可以通过其他路径暴露同一文件;调用方可通过报告的 `partial` 强制级别检测此限制。 +在 Windows 上,本包将子进程的写入与删除限制在工作区和私有临时目录内:`workspace-write` 授予这两处,`read-only` 均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 与 PowerShell 命令即获得此行为;调用方也可以直接使用公开 `AclSandbox` API;任何 Win32 操作失败都会阻止不受限制的 spawn。每次授权同时写入能力 SID 允许 ACE、对父目录删除权限的环境性拒绝,以及被降级令牌必须匹配的 Low 完整性标签,因此一个授权根目录无法触及另一个。该保证仍为部分强制:硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。 ## 目录 @@ -59,15 +59,15 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure +sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair rmSync(tempDir, { recursive: true, force: true }) ``` -工作区 ACE 以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID。 +工作区的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE、环境性删除拒绝与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。 ### 隔离给你带来什么 -在 `workspace-write` 下,子进程可以写入工作区及其私有临时目录;受 ACL 管辖的其他写入都会被拒绝,已记录的 Everyone 与硬链接边界除外。在 `read-only` 下不存在显式写入授权,因此写入会被拒绝,同样带有已记录的边界。 +在 `workspace-write` 下,子进程可以写入工作区及其私有临时目录;受 ACL 管辖的其他写入与删除都会被拒绝,已记录的硬链接与 AppContainer ACL 边界除外。在 `read-only` 下不存在显式写入授权,因此写入与删除都会被拒绝,同样带有已记录的边界。 临时隔离按每个活跃的会话/工作区对进行:共享工作区的会话共享其写权限,但无法写入彼此的临时目录。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。 @@ -87,11 +87,11 @@ rmSync(tempDir, { recursive: true, force: true }) ### 机制 -调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE 跳过。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。 +调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录的安全描述符改动每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE/精确拒绝/精确标签跳过。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。 ### 模式与令牌列表 -`workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID)为工作区与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻 ACE 让重新升级免于重新传播。 +`workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID)为工作区与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。 NUL 写入是环境性的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在两种模式下都能写。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,非设备 DACL 所致),而 PowerShell 的 `> $null` 重定向不受影响。 @@ -111,15 +111,18 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 ### 已验证边界 -- **Everyone 授权仍是环境中的写权限来源。** Everyone 必须保留在两种 restricting 列表中(移除它会破坏早期 DLL 初始化与 CNG);外部 NTFS 对象若其 DACL 向 Everyone 授予所请求的写权限,就会同时通过两次检查,并在两种模式下保持可写。 -- **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承工作区 ACE 会修改底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。 -- **写入受限;读取、网络与进程可见性不受限。** `WRITE_RESTRICTED` 只交叉检查写访问,因此受限子进程可以读取调用者可读的任何文件并打开套接字;`read-only` 因而需要读侧策略才能表达。 +- **Everyone 仍留在两种 restricting 列表中,但不再带来写权限。** 保活组是早期 DLL 初始化与 CNG 所必需的;如今 Low 标签会拒绝对被标记根目录之外、由 Everyone 授权的写入,因此这一旧缺口已关闭。 +- **在授权根目录内,能力 ACE 的 DELETE 位是唯一的删除授权来源。** 授权会向 world SID 拒绝 `FILE_DELETE_CHILD`,这同时移除了环境性默认行为:自身 DACL 未授予 DELETE 的文件不再能凭父目录权限删除——受限子进程与用户自身进程皆然。用户日常删除仍然可用,因为工作区 DACL 直接向其授予 DELETE。 +- **拒绝项只继承到子目录,且子目录的 FullControl 打开会被拒。** `FILE_DELETE_CHILD` 只在目录上被评估,因此该 ACE 带 `CONTAINER_INHERIT_ACE`、绝不落到文件上(它的位 `0x40` 属于 `FILE_ALL_ACCESS`,若落到文件上会让用户、Administrators、SYSTEM 或 DSH host 的每次 `GENERIC_ALL`/`FullControl` 打开都被拒绝)。授权根内的目录保留该拒绝项,因而会拒绝这类打开;基于 `DELETE` 的删除、`MAXIMUM_ALLOWED` 与常规读写打开不受影响——两种结果都已被 runner 套件钉住。 +- **写入与删除受限;读取、网络与进程可见性不受限。** 两层都不交叉检查读取,因此受限子进程可以读取调用者可读的任何文件(包括其他工作区中的文件)并打开套接字;`read-only` 因而需要读侧策略才能表达。 +- **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承工作区授权会标记并授权底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。 - **控制台隔离不可用。** 以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡;子进程共享宿主控制台,基于管道的 stdio 重定向不受影响。 -- **ACL 授权是对真实目录的驻留改动。** 工作区 ACE 按设计常驻(复用缓存,绝不撤销);临时 ACE 由 `dispose()` 撤销;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 -- **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙箱无需提权即可编辑 DACL 的原因。 +- **安全描述符改动是对真实目录的驻留改动。** 工作区 ACE、拒绝项与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销——但若该目录上仍留有其他能力授权,撤销会保留共享的 Low 标签——撤销后残留的那条拒绝项会随临时目录本身一并消失;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 +- **常驻 Low 标签的生命期长于 DSH,并会向其他 Low 完整性的进程放宽该目录树。** 工作区的可继承标签在会话结束后(以及同卷移动后)依然存在,因此任何以同一用户身份运行在 Low 完整性的其他进程——别家产品的 Low-IL 沙箱、受保护模式阅读器——都能写入与删除工作区内的内容,而在 Medium 标签下这会被拒绝。这个标签是写边界的代价:没有它受限子进程根本无法写入,而按会话回收会导致每次供给都要重新传播整棵树。 +- **被授权目录必须由调用者拥有并授予 `WRITE_OWNER`。** 所有者隐式获得的只有 `READ_CONTROL` 与 `WRITE_DAC`;标签位于 SACL,因此合并应用还需要 `WRITE_OWNER`(完全控制目录——即正常工作区情形——本就具备)。DACL 只授予 Modify 的目录现在会大声失败,而不是静默跳过隔离。 - **环境临时根目录绝不会被隐式授权。** 直接调用方必须提供已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或用 `tempDir: null` 禁用临时写入;实际临时目录不得与任何可写根目录重叠。 - **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。 -- **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,这是诊断噪音而非运行故障。 +- **受限令牌下 `whoami` 与令牌检查 cmdlet 可能失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此这类报告型 cmdlet 是否可用取决于宿主环境;这是诊断噪音而非运行故障。 ### 头文件验证与源码索引 @@ -173,7 +176,8 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 - **受限孙进程的管道 stdio 捕获不可用。** libuv 的管道 stdio 用的是 NAMED pipe,其 client 端打开所请求的写访问没有任何 restricting SID 被授予(是 Win32 层的默认 SD 模板,而非令牌默认 DACL),因此受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败;继承与忽略 stdio 的 spawn 可用,匿名管道(PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE 而可用。 - **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(大型工作区树上以数十秒计);按工作区身份每台机器每个工作区只付一次,精确 ACE 跳过让后续每次供给都很便宜。 - **读侧隔离与网络策略不在范围内**——`WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 -- **宽目录与 FAT 卷警告已推迟;FAT 类目标保持可写。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标没有安全描述符,因此在两种受限模式下都可写;FAT 被视为遗留残留。 +- **读取会被其他基于 AppContainer 的工具以包 SID 授权过的对象挡住。** 在本机上,当文件的 DACL 携带针对包 SID(`S-1-15-2-…`)的 ACE 时,Low 完整性的令牌无法访问它——即使同一份 DACL 同时向用户授予完全控制、向 Everyone 授予读取(已观测:只给新文件加这一条 ACE 即可复现拒绝,补授 Everyone 读取无法解除,而同样内容复制到别处仍可读)。其背后的内核规则尚未确证,也不由本包掌控;以 AppContainer 自我隔离的工具正是会写入这类 ACE,因此被它们标记过的目录树对本后端的子进程将不可读。移除外来 ACE(或重新安装受影响的目录树)即可恢复访问。 +- **宽目录与 FAT 卷警告已推迟;FAT 类残留未经验证。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标不存储安全描述符;其有效完整性标签由系统分配而非记录在对象上,因此标签层在该处的行为未经测试。FAT 仍被视为遗留残留。 - **PowerShell 语言模式因受限模式而异。** 在 `read-only` 下,PowerShell 无法在临时目录中创建 AppLocker 探针文件,因此会保守地以 ConstrainedLanguage 启动(`Add-Type`、非核心 .NET 静态调用、COM 与反射失败);交付的 `workspace-write` 路径可让探针完成,因此除非主机范围的 WDAC/AppLocker 策略另有规定,否则 pwsh 保持 FullLanguage,而直接使用 `AclSandbox` 并配置 `tempDir: null` 时则没有这一保证。这一区别属于 PowerShell 启动行为,不是 ACL 写入边界的一部分。 diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index eded0f86a8..82f7030b67 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -5,6 +5,15 @@ * failure is reported with the API name, the exact Win32 code, the formatted * system text, and the affected path. * + * Each grant applies three edits in ONE SetNamedSecurityInfoW call: the + * capability-SID allow ACE, a Deny ACE that removes the ambient + * `FILE_DELETE_CHILD` right from the world SID, and a Low no-write-up + * mandatory label ({@link buildLowLabelAcl}). The deny is what keeps one + * granted root out of another's reach: Windows also authorizes a delete from + * the parent directory's `FILE_DELETE_CHILD` right, which the token's + * write-restricted intersection does not reach, and every granted root carries + * the Low label that clears the integrity check. + * * Concurrency: grants are read-merge-write against the directory's CURRENT * DACL, and the whole get-merge-set sequence runs under a per-path exclusive * LockFileEx lock (see {@link withPathLock}) so concurrent sandbox instances @@ -25,17 +34,26 @@ import * as abi from './win32-abi.ts' * perms@0, mode@4, inheritance@8, Trustee@16 { pMultipleTrustee@16, * MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }. * `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which - * removes every ACE for the trustee. + * removes every ACE for the trustee. `inheritance` defaults to children of + * both kinds; the ambient-delete deny narrows it to containers because + * FILE_DELETE_CHILD is meaningless on a file and its bit would otherwise + * spread through the file's inherited mask. * @param sidPtr - the trustee SID the entry names. - * @param mode - the access mode (GRANT_ACCESS or REVOKE_ACCESS). - * @param permissions - the access mask to grant (0 for REVOKE_ACCESS). + * @param mode - the access mode (GRANT_ACCESS, DENY_ACCESS, or REVOKE_ACCESS). + * @param permissions - the access mask to grant or deny (0 for REVOKE_ACCESS). + * @param inheritance - the ACE inheritance flags. * @returns the packed entry buffer. */ -export function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer { +export function buildExplicitAccess( + sidPtr: NativePtr, + mode: number, + permissions: number, + inheritance: number = abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, +): Buffer { const entry = Buffer.alloc(abi.EXPLICIT_ACCESS_W_SIZE) entry.writeUInt32LE(permissions, 0) // grfAccessPermissions entry.writeUInt32LE(mode, 4) // grfAccessMode - entry.writeUInt32LE(abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, 8) // grfInheritance: OI|CI + entry.writeUInt32LE(inheritance, 8) // grfInheritance entry.writeUInt32LE(abi.NO_MULTIPLE_TRUSTEE, 24) // Trustee.MultipleTrusteeOperation entry.writeUInt32LE(abi.TRUSTEE_IS_SID, 28) // Trustee.TrusteeForm entry.writeUInt32LE(abi.TRUSTEE_IS_UNKNOWN, 32) // Trustee.TrusteeType @@ -109,59 +127,125 @@ export function withPathLock(api: Win32Bindings, path: string, action: () => } /** - * Read the directory's current explicit DACL via GetNamedSecurityInfoW. + * Read the directory's current explicit DACL and mandatory label via + * GetNamedSecurityInfoW. * Allocation contract (the POC's RevokeAccess, minus its missing checks): the * returned ACL pointer sits INSIDE the security descriptor allocation — only * the descriptor may be LocalFree'd, and it must not be freed before * SetEntriesInAclW has consumed the ACL. Freeing the ACL pointer itself * corrupts the heap (verified the hard way). * @param api - the binding table. - * @param path - the directory whose DACL is read. - * @returns the current explicit DACL (null when the directory carries none) and its owning descriptor. + * @param path - the directory whose DACL and label are read. + * @returns the current explicit DACL and label ACL (null when the directory carries none) plus their owning descriptor. */ -function readCurrentDacl(api: Win32Bindings, path: string): { oldAcl: NativePtr | null; descriptor: NativePtr | null } { +function readCurrentSecurity( + api: Win32Bindings, + path: string, +): { oldAcl: NativePtr | null; labelAcl: NativePtr | null; descriptor: NativePtr | null } { const ownerSlot = allocPtrSlot() const groupSlot = allocPtrSlot() const daclSlot = allocPtrSlot() const saclSlot = allocPtrSlot() const descriptorSlot = allocPtrSlot() const readResult = api.getNamedSecurityInfoW( - path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION | abi.LABEL_SECURITY_INFORMATION, ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, ) if (readResult !== abi.ERROR_SUCCESS) throwWin32(api, 'GetNamedSecurityInfoW', readResult, path) - return { oldAcl: decodePtr(daclSlot), descriptor: decodePtr(descriptorSlot) } + return { oldAcl: decodePtr(daclSlot), labelAcl: decodePtr(saclSlot), descriptor: decodePtr(descriptorSlot) } } /** - * Shared tail of grantWrite and revokeWrite: merge `entry` into `oldAcl` - * (null = no explicit DACL yet; SetEntriesInAclW builds one from scratch), - * free the descriptor before applying the merged ACL, apply it, then free the - * merged ACL — checking every call and reporting with the caller's label. + * Build the Low mandatory label applied with every write grant: one + * SYSTEM_MANDATORY_LABEL_ACE naming `lowLabelSidPtr` with the no-write-up + * policy, inheriting to subcontainers and objects so later children carry the + * same label. The caller frees the returned ACL with LocalFree + * (SetNamedSecurityInfoW copies it); every Win32 call is checked and a + * half-built ACL is released before the error is thrown. * @param api - the binding table. - * @param path - the directory the DACL edit applies to. - * @param entry - the EXPLICIT_ACCESS_W to merge (grant or revoke). - * @param oldAcl - the current explicit DACL (from {@link readCurrentDacl}). + * @param lowLabelSidPtr - the Low integrity SID (S-1-16-4096) the label names. + * @returns the ACL carrying the single inheritable label ACE. + */ +export function buildLowLabelAcl(api: Win32Bindings, lowLabelSidPtr: NativePtr): NativePtr { + const sidLength = api.getLengthSid(lowLabelSidPtr) + if (sidLength === 0) throwLastError(api, 'GetLengthSid', 'Low mandatory label SID') + const aclLength = abi.ACL_HEADER_SIZE + abi.MANDATORY_ACE_OVERHEAD + sidLength + const acl = api.localAlloc(abi.LPTR, aclLength) + if (isNullPtr(acl)) throwLastError(api, 'LocalAlloc', 'Low mandatory label ACL') + if (api.initializeAcl(acl, aclLength, abi.ACL_REVISION) === 0) { + const win32Code = api.getLastError() + api.localFree(acl) // best-effort on the error path + throwWin32(api, 'InitializeAcl', win32Code, 'Low mandatory label ACL') + } + if (api.addMandatoryAce( + acl, abi.ACL_REVISION, abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, lowLabelSidPtr, + ) === 0) { + const win32Code = api.getLastError() + api.localFree(acl) // best-effort on the error path + throwWin32(api, 'AddMandatoryAce', win32Code, 'Low mandatory label ACL') + } + return acl +} + +/** + * True when the label ACL already carries the EXACT label this module would + * add (mandatory-label ACE, OI|CI inheritance, no-write-up policy, the Low + * SID), so a re-grant can skip the eager full-tree propagation. + * @param labelAcl - the current label ACL pointer (from {@link readCurrentSecurity}). + * @param lowLabelSidPtr - the Low integrity SID to match. + * @returns whether the exact label ACE is already present. + */ +function hasExactLabel(labelAcl: NativePtr, lowLabelSidPtr: NativePtr): boolean { + return hasExactEntry( + labelAcl, abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE, abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, + abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, lowLabelSidPtr, + ) +} + +/** + * The mandatory-label half of an apply: set the given label ACL, clear the + * label, or leave it untouched. `clear` is what a revoke does when the + * directory carries no other capability grant; `keep` is what it does while + * another grant still relies on the shared Low level. + */ +type LabelEdit = { kind: 'apply'; acl: NativePtr } | { kind: 'clear' } | { kind: 'keep' } + +/** + * Shared tail of grantWrite and revokeWrite: merge `entries` into `oldAcl` + * (null = no explicit DACL yet; SetEntriesInAclW builds one from scratch), + * free the descriptor before applying the merged ACL, apply the merged DACL + * together with the label edit in one SetNamedSecurityInfoW call, then free + * every ACL this call owns — checking each call and reporting with the + * caller's label. The entry count derives from the buffer, so a grant can + * carry its capability ACE and its ambient-delete deny in one merge. + * @param api - the binding table. + * @param path - the directory the DACL and label edits apply to. + * @param entries - packed EXPLICIT_ACCESS_W records to merge (grant, deny, or revoke). + * @param oldAcl - the current explicit DACL (from {@link readCurrentSecurity}). + * @param labelEdit - the label change to apply alongside the DACL. * @param descriptor - the descriptor allocation owning `oldAcl`. * @param label - the caller's name for error details. */ function mergeAndApply( api: Win32Bindings, path: string, - entry: Buffer, + entries: Buffer, oldAcl: NativePtr | null, + labelEdit: LabelEdit, descriptor: NativePtr | null, label: string, ): void { const newAclSlot = allocPtrSlot() - const mergeResult = api.setEntriesInAclW(1, entry, oldAcl, newAclSlot) + const mergeResult = api.setEntriesInAclW(entries.length / abi.EXPLICIT_ACCESS_W_SIZE, entries, oldAcl, newAclSlot) if (mergeResult !== abi.ERROR_SUCCESS) { if (descriptor !== null) api.localFree(descriptor) // frees the ACL block too + if (labelEdit.kind === 'apply') api.localFree(labelEdit.acl) throwWin32(api, 'SetEntriesInAclW', mergeResult, `${label}(${path})`) } const newAcl = decodePtr(newAclSlot) if (newAcl === null) { if (descriptor !== null) api.localFree(descriptor) + if (labelEdit.kind === 'apply') api.localFree(labelEdit.acl) throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `${label}(${path}): null new ACL`) } @@ -169,44 +253,98 @@ function mergeAndApply( // before applying, exactly like the POC. const freedDescriptor = descriptor !== null ? api.localFree(descriptor) : null const applyResult = api.setNamedSecurityInfoW( - path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, - null, null, newAcl, null, + path, abi.SE_FILE_OBJECT, + labelEdit.kind === 'keep' ? abi.DACL_SECURITY_INFORMATION : abi.DACL_SECURITY_INFORMATION | abi.LABEL_SECURITY_INFORMATION, + null, null, newAcl, labelEdit.kind === 'apply' ? labelEdit.acl : null, ) const freedNew = api.localFree(newAcl) + const freedLabel = labelEdit.kind === 'apply' ? api.localFree(labelEdit.acl) : null if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `${label}(${path})`) if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, 'LocalFree', `${label}(${path}) descriptor`) if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `${label}(${path}) new ACL`) + if (freedLabel !== null && !isNullPtr(freedLabel)) throwLastError(api, 'LocalFree', `${label}(${path}) label ACL`) +} + +/** + * True when the explicit DACL already carries the EXACT entry + * `(aceType, inheritance, mask, trustee SID)`. Every field is read through + * koffi.decode at pointer offsets — no memcpy, no pointer arithmetic. The + * ACE's SID is INLINE (embedded in the ACE after the 4-byte mask — there is + * no pointer to read; reading one yields garbage addresses and crashed + * EqualSid, verified by gdb), so it is compared field-by-field against the + * trustee SID through bounded offset reads ({@link sameSidAt}). Allowed and + * denied ACEs share the Mask@4/SID@8 layout. A malformed header reads as "no + * exact entry" so the caller falls back to the merge-apply path, which owns + * the robust failure handling. + * @param acl - the current explicit DACL pointer (from {@link readCurrentSecurity}). + * @param aceType - the ACE type to match. + * @param inheritance - the ACE inheritance flags to match. + * @param mask - the access mask to match. + * @param sidPtr - the trustee SID to match. + * @returns whether the exact entry is already present. + */ +function hasExactEntry(acl: NativePtr, aceType: number, inheritance: number, mask: number, sidPtr: NativePtr): boolean { + const aclSize = decodeUint16At(acl, 2) + const aceCount = decodeUint16At(acl, 4) + if (aclSize < 8 || aclSize > 1_048_576) return false // implausible: fall back to the merge path + let offset = 8 // the first ACE follows the 8-byte ACL header + for (let index = 0; index < aceCount; index++) { + // ACE_HEADER: AceType@0, AceFlags@1, AceSize@2 (WORD); Mask@4, inline SID@8. + const aceSize = decodeUint16At(acl, offset + 2) + if (aceSize < 8 || offset + aceSize > aclSize) return false // implausible: fall back to the merge path + const exact = decodeUint8At(acl, offset) === aceType + && decodeUint8At(acl, offset + 1) === inheritance + && decodeUint32At(acl, offset + 4) === mask + if (exact && sameSidAt(acl, offset + 8, sidPtr, 0)) return true + offset += aceSize + } + return false } /** * True when the explicit DACL already carries the EXACT write grant this - * module would add (Allow ACE, OI|CI inheritance, {@link abi.GRANT_MASK}, the - * capability SID). Every field is read through koffi.decode at pointer offsets — - * no memcpy, no pointer arithmetic. The ACE's SID is INLINE (embedded in the - * ACE after the 4-byte mask — there is no pointer to read; reading one - * yields garbage addresses and crashed EqualSid, verified by gdb), so it is - * compared field-by-field against the capability SID through bounded offset - * reads ({@link sameSidAt}). A malformed header reads as "no exact grant" - * so the caller falls back to the merge-apply path, which owns the robust - * failure handling. - * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentDacl}). + * module would add: the Allow ACE for {@link abi.GRANT_MASK} naming the + * capability SID. + * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}). * @param sidPtr - the capability SID to match. * @returns whether the exact grant ACE is already present. */ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { + return hasExactEntry(oldAcl, abi.ACCESS_ALLOWED_ACE_TYPE, abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, abi.GRANT_MASK, sidPtr) +} + +/** + * True when the explicit DACL already carries the EXACT ambient-delete deny: + * the container-inherited Deny ACE for {@link abi.FILE_DELETE_CHILD} naming + * the world SID. It is part of the idempotent skip, so a root granted by an + * earlier build receives the deny on its next provision. + * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}). + * @param worldSidPtr - the Everyone SID the deny names. + * @returns whether the exact deny ACE is already present. + */ +function hasExactDeny(oldAcl: NativePtr, worldSidPtr: NativePtr): boolean { + return hasExactEntry(oldAcl, abi.ACCESS_DENIED_ACE_TYPE, abi.CONTAINER_INHERIT_ACE, abi.FILE_DELETE_CHILD, worldSidPtr) +} + +/** + * True when a capability grant for a SID OTHER than `sidPtr` stands on this + * DACL — the condition under which a revoke must leave the shared Low label in + * place, or the remaining grant's child would lose its write authority. + * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}). + * @param sidPtr - the capability SID being revoked. + * @returns whether another capability grant remains. + */ +function hasForeignGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { const aclSize = decodeUint16At(oldAcl, 2) const aceCount = decodeUint16At(oldAcl, 4) - if (aclSize < 8 || aclSize > 1_048_576) return false // implausible: fall back to the merge path - let offset = 8 // the first ACE follows the 8-byte ACL header + if (aclSize < 8 || aclSize > 1_048_576) return false // implausible: leave the label alone + let offset = 8 for (let index = 0; index < aceCount; index++) { - // ACE_HEADER: AceType@0, AceFlags@1, AceSize@2 (WORD); - // ACCESS_ALLOWED_ACE: Mask@4, inline SID@8. const aceSize = decodeUint16At(oldAcl, offset + 2) - if (aceSize < 8 || offset + aceSize > aclSize) return false // implausible: fall back to the merge path - const exact = decodeUint8At(oldAcl, offset) === abi.ACCESS_ALLOWED_ACE_TYPE - && decodeUint8At(oldAcl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT + if (aceSize < 8 || offset + aceSize > aclSize) return false // implausible: leave the label alone + const isGrant = decodeUint8At(oldAcl, offset) === abi.ACCESS_ALLOWED_ACE_TYPE && decodeUint32At(oldAcl, offset + 4) === abi.GRANT_MASK - if (exact && sameSidAt(oldAcl, offset + 8, sidPtr, 0)) return true + if (isGrant && !sameSidAt(oldAcl, offset + 8, sidPtr, 0)) return true offset += aceSize } return false @@ -214,42 +352,77 @@ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { /** * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the capability SID - * on `path`, inheriting to subcontainers and objects. Idempotent: when the - * directory's current explicit DACL already carries the exact ACE (the - * per-session grant surviving from a previous server lifetime), the - * SetNamedSecurityInfoW apply is SKIPPED — it would otherwise re-propagate - * the identical ACE across the whole tree (eager inheritance; minutes on - * large workspaces). Otherwise read-merge-write: the new ACE merges into the - * directory's CURRENT explicit DACL (same shape as {@link revokeWrite}), so - * pre-existing explicit ACEs survive. Runs under the per-path lock. The - * directory must be owned by the caller (owner implicit WRITE_DAC) — same - * precondition as the POC. + * on `path`, deny the world SID the ambient `FILE_DELETE_CHILD` right, and + * apply the Low mandatory label — one merge. The deny inherits to containers + * only: the right is evaluated on directories, and inheriting its bit onto + * files would deny every `FILE_ALL_ACCESS`/`GENERIC_ALL` open inside the root + * (0x40 is a member of that mask). The capability ACE's DELETE bit is then the + * only delete authority inside the root, so a file whose own DACL grants no + * DELETE is no longer deletable through its parent's rights. + * + * Idempotent: the exact ACE, deny, and label together SKIP the + * SetNamedSecurityInfoW apply, which would otherwise re-propagate the + * identical descriptor across the whole tree (eager inheritance; minutes on + * large workspaces). Otherwise read-merge-write, so pre-existing explicit ACEs + * survive (same shape as {@link revokeWrite}). Runs under the per-path lock. + * The directory must be owned by the caller AND grant WRITE_OWNER (the label + * lives in the SACL; owner-implicit rights cover only READ_CONTROL and + * WRITE_DAC) — a Full-control workspace satisfies both. * @param api - the binding table. - * @param path - the directory whose DACL gains the grant (the workspace or temp root). + * @param path - the directory whose DACL and label gain the grant (the workspace or temp root). * @param sidPtr - the capability SID the ACE names. + * @param lowLabelSidPtr - the Low integrity SID the mandatory label names. + * @param worldSidPtr - the Everyone SID the ambient-delete deny names. */ -export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { +export function grantWrite( + api: Win32Bindings, + path: string, + sidPtr: NativePtr, + lowLabelSidPtr: NativePtr, + worldSidPtr: NativePtr, +): void { withPathLock(api, path, () => { - const { oldAcl, descriptor } = readCurrentDacl(api, path) - if (oldAcl !== null && hasExactGrant(oldAcl, sidPtr)) { - // The exact ACE stands: releasing the descriptor is the whole operation. + const { oldAcl, labelAcl, descriptor } = readCurrentSecurity(api, path) + if (oldAcl !== null && labelAcl !== null + && hasExactGrant(oldAcl, sidPtr) && hasExactDeny(oldAcl, worldSidPtr) + && hasExactLabel(labelAcl, lowLabelSidPtr)) { + // The exact ACE, deny, and label stand: releasing the descriptor is the whole operation. if (descriptor !== null) { const freed = api.localFree(descriptor) if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path}) descriptor`) } return } - mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), oldAcl, descriptor, 'grantWrite') + let label: NativePtr + try { + label = buildLowLabelAcl(api, lowLabelSidPtr) + } catch (error) { + // The read already owns a descriptor allocation; release it before the + // label failure propagates. + if (descriptor !== null) api.localFree(descriptor) + throw error + } + mergeAndApply( + api, path, + Buffer.concat([ + buildExplicitAccess(worldSidPtr, abi.DENY_ACCESS, abi.FILE_DELETE_CHILD, abi.CONTAINER_INHERIT_ACE), + buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), + ]), + oldAcl, { kind: 'apply', acl: label }, descriptor, 'grantWrite', + ) }) } /** * Remove every ACE for the capability SID from the directory DACL (REVOKE_ACCESS - * merge — other entries are preserved). Returns whether an ACE removal was - * attempted (false when the directory carries no DACL at all). + * merge — other entries are preserved). The shared Low label is cleared only + * when no other capability grant remains on the directory: two grants may + * target one directory, and the surviving one still needs the label for its + * child's writes. Returns whether an ACE removal was attempted (false when the + * directory carries no DACL at all). * * Runs under the per-path lock (the whole get-merge-set sequence); the - * descriptor/ACL allocation contract lives on {@link readCurrentDacl}. + * descriptor/ACL allocation contract lives on {@link readCurrentSecurity}. * @param api - the binding table. * @param path - the directory whose DACL loses the capability-SID ACEs. * @param sidPtr - the capability SID whose ACEs are removed. @@ -257,7 +430,7 @@ export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): */ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean { return withPathLock(api, path, () => { - const { oldAcl, descriptor } = readCurrentDacl(api, path) + const { oldAcl, descriptor } = readCurrentSecurity(api, path) if (oldAcl === null) { if (descriptor !== null) { const freed = api.localFree(descriptor) @@ -265,7 +438,11 @@ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr) } return false } - mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, descriptor, 'revokeWrite') + mergeAndApply( + api, path, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, + hasForeignGrant(oldAcl, sidPtr) ? { kind: 'keep' } : { kind: 'clear' }, + descriptor, 'revokeWrite', + ) return true }) } diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index eed123225c..5ef39a4530 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -60,6 +60,8 @@ export interface Win32Bindings extends Win32ProcessBindings { newToken: NativePtr, ): number setEntriesInAclW(count: number, entries: Buffer, oldAcl: NativePtr | null, newAcl: NativePtr): number + initializeAcl(acl: NativePtr, length: number, revision: number): number + addMandatoryAce(acl: NativePtr, revision: number, aceFlags: number, policy: number, sid: NativePtr): number setNamedSecurityInfoW( path: string, objectType: number, @@ -67,7 +69,7 @@ export interface Win32Bindings extends Win32ProcessBindings { owner: null, group: null, dacl: NativePtr | null, - sacl: null, + sacl: NativePtr | null, ): number getNamedSecurityInfoW( path: string, @@ -252,6 +254,8 @@ function bindings(): Win32Bindings { PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID, ]), setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]), + initializeAcl: bind(advapi32, 'InitializeAcl', 'int', [PVOID, 'uint32', 'uint32']), + addMandatoryAce: bind(advapi32, 'AddMandatoryAce', 'int', [PVOID, 'uint32', 'uint32', 'uint32', PVOID]), setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', [ 'str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID, ]), diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index edb0345579..7f7bcc4c57 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -7,41 +7,57 @@ * * Fail-closed: `add` throws on any grant failure and the caller disposes the * instance (revoking every path granted so far); `dispose` revokes every - * standing grant and reports every cleanup failure. + * revocable grant, leaves the standing workspace edits in place, and reports + * every cleanup failure. * @module @deepseek-ai/dsh-sandbox-windows-acl/grant */ import { grantWrite, revokeWrite } from './acl.ts' import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' +import { makeWellKnownSid } from './token.ts' +import * as abi from './win32-abi.ts' /** * One write SID's provider-lifetime grant materialization: the parsed SID - * pointer plus every directory whose DACL currently carries its ACE. - * Workspace paths are added STANDING (their ACEs are the cross-session reuse - * cache and outlive the grant — dispose() skips revoking them, or the next + * pointer plus every directory whose DACL currently carries its ACE and whose + * label ACL carries the Low mandatory label. Workspace paths are added + * STANDING (their security descriptor edits are the cross-session reuse cache + * and outlive the grant — dispose() skips revoking them, or the next * provision would re-propagate the whole tree); temp paths are revocable * (dispose() revokes them — an inheritable ACE must not outlive its * session's temp directory). Create with {@link AclWriteGrant.create}; - * dispose revokes the revocable paths and frees the SID. + * dispose revokes the revocable paths and frees every SID. */ export class AclWriteGrant { /** The write SID in SDDL string form. */ readonly writeSid: string private readonly api: Win32Bindings private readonly sidPtr: NativePtr + private readonly lowLabelSidPtr: NativePtr + private readonly worldSidPtr: NativePtr private readonly revocablePaths: string[] = [] private readonly standingPaths: string[] = [] - private constructor(api: Win32Bindings, sidPtr: NativePtr, writeSid: string) { + private constructor( + api: Win32Bindings, + sidPtr: NativePtr, + lowLabelSidPtr: NativePtr, + worldSidPtr: NativePtr, + writeSid: string, + ) { this.api = api this.sidPtr = sidPtr + this.lowLabelSidPtr = lowLabelSidPtr + this.worldSidPtr = worldSidPtr this.writeSid = writeSid } /** - * Parse the SID string and open the binding table (lazily, once per - * server). Fail-closed: any failure throws — nothing is granted yet. + * Parse the SID string, create the Low integrity SID the grants label with + * and the world SID their ambient-delete deny names, and open the binding + * table (lazily, once per server). Fail-closed: any failure throws — nothing + * is granted yet. * @param writeSid - the workspace (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string. * @param api - optional already-resolved bindings (tests). * @returns the ready grant (no ACEs yet). @@ -54,26 +70,41 @@ export class AclWriteGrant { } const sidPtr = decodePtr(sidSlot) if (sidPtr === null) throwLastError(bindings, 'ConvertStringSidToSidW', `null SID for ${writeSid}`) - return new AclWriteGrant(bindings, sidPtr, writeSid) + try { + const lowLabelSidPtr = makeWellKnownSid(bindings, abi.WinLowLabelSid) + try { + const worldSidPtr = makeWellKnownSid(bindings, abi.WinWorldSid) + return new AclWriteGrant(bindings, sidPtr, lowLabelSidPtr, worldSidPtr, writeSid) + } catch (error) { + // The Low label SID is LocalAlloc'd: release it before the world-SID + // failure propagates to the sidPtr release below. + bindings.localFree(lowLabelSidPtr) + throw error + } + } catch (error) { + bindings.localFree(sidPtr) + throw error + } } /** - * Grant the write ACE on one directory (idempotent: an already-standing - * exact ACE skips the eager full-tree re-propagation — see - * {@link grantWrite}) and record the path for {@link dispose} unless it is - * standing. The path is recorded BEFORE the grant: a post-apply throw (a - * LocalFree failure after SetNamedSecurityInfoW succeeded) must still - * revoke it, and revoking an ungranted path is a no-op merge. Callers - * treat a throw as a failed materialization and dispose the instance to - * revoke the paths granted so far. - * @param path - the directory whose DACL gains the grant. - * @param standing - the ACE outlives this grant (the workspace reuse + * Grant the write ACE, the ambient-delete deny, and the Low mandatory label + * on one directory (idempotent: an already-standing exact ACE, deny, and + * label skip the eager full-tree re-propagation — see {@link grantWrite}) + * and record the path for {@link dispose} unless it is standing. The path is + * recorded BEFORE the grant: a post-apply throw (a LocalFree failure after + * SetNamedSecurityInfoW succeeded) must still revoke it, and revoking an + * ungranted path is a no-op merge. Callers treat a throw as a failed + * materialization and dispose the instance to revoke the paths granted so + * far. + * @param path - the directory whose DACL and label gain the grant. + * @param standing - the edits outlive this grant (the workspace reuse * cache; dispose() skips revoking it). Default false (revoked on * dispose — the temp-directory lifecycle). */ add(path: string, standing = false): void { ;(standing ? this.standingPaths : this.revocablePaths).push(path) - grantWrite(this.api, path, this.sidPtr) + grantWrite(this.api, path, this.sidPtr, this.lowLabelSidPtr, this.worldSidPtr) } /** Every directory currently carrying the grant, in grant order. */ @@ -81,7 +112,7 @@ export class AclWriteGrant { return [...this.standingPaths, ...this.revocablePaths] } - /** Revoke every revocable grant (standing ACEs stay) and free the SID; reports every cleanup failure. */ + /** Revoke every revocable grant (standing security descriptor edits stay) and free the SIDs; reports every cleanup failure. */ dispose(): void { const failures: unknown[] = [] for (const path of this.revocablePaths) { @@ -91,11 +122,17 @@ export class AclWriteGrant { failures.push(error) } } - try { - const freed = this.api.localFree(this.sidPtr) - if (!isNullPtr(freed)) throwLastError(this.api, 'LocalFree', 'write SID') - } catch (error) { - failures.push(error) + for (const [label, sidPtr] of [ + ['write SID', this.sidPtr], + ['Low label SID', this.lowLabelSidPtr], + ['world SID', this.worldSidPtr], + ] as const) { + try { + const freed = this.api.localFree(sidPtr) + if (!isNullPtr(freed)) throwLastError(this.api, 'LocalFree', label) + } catch (error) { + failures.push(error) + } } if (failures.length > 0) { throw new AggregateError(failures, `AclWriteGrant dispose completed with ${failures.length} cleanup failure(s)`) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 70e7661993..75dd23e7a5 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -10,7 +10,10 @@ * keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE, * and LOCAL are absent from both lists — see the seam's dual-list contract * in `packages/sandbox/sandbox-local` and the package README's Modes section - * for the complete boundary). The write SID is the per-WORKSPACE identity + * for the complete boundary). The intersection covers only the object's own + * access check, so the token is also lowered to Low integrity and every + * granted directory carries a Low no-write-up label and the ambient-delete + * deny the `acl` module documents. The write SID is the per-WORKSPACE identity * ({@link workspaceWriteSid}): deterministic from the canonical workspace * path, so the workspace-root ACE materializes once per workspace per * machine and every later provision hits the exact-ACE skip — the @@ -49,7 +52,7 @@ import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi import type { NativePtr, Win32Bindings } from './ffi.ts' import { assertPrivateTempDisjoint } from './path-boundary.ts' import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts' -import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts' +import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, restrictTokenIntegrity, setTokenDefaultDaclGrant } from './token.ts' import * as abi from './win32-abi.ts' export { AclWriteGrant } from './grant.ts' @@ -256,30 +259,34 @@ export class AclSandbox { // provision would re-propagate the whole tree) and the temp ACE is // REVOCABLE (dispose() removes it before the private directory is // deleted; the ambient temp root is never granted). + // The Low label SID and the world SID the grants deny and label with. + const lowLabelSid = makeWellKnownSid(api, abi.WinLowLabelSid) + const worldSid = makeWellKnownSid(api, abi.WinWorldSid) + this.sidAllocations.push(lowLabelSid, worldSid) + if (this.manageDacls) { if (this.writeSidPtr !== undefined) { for (const path of this.writableDirs) { - grantWrite(api, path, this.writeSidPtr) + grantWrite(api, path, this.writeSidPtr, lowLabelSid, worldSid) } if (tempDir !== null && this.tempWriteSidPtr !== undefined) { // Record BEFORE granting: grantWrite can throw after a successful // apply (a LocalFree failure), and the fail-closed catch must still // revoke that path (revoking an ungranted path is a no-op merge). this.grantedPaths.push({ path: tempDir, sidPtr: this.tempWriteSidPtr }) - grantWrite(api, tempDir, this.tempWriteSidPtr) + grantWrite(api, tempDir, this.tempWriteSidPtr, lowLabelSid, worldSid) } } } const logonSid = findLogonSid(api, currentToken) this.sidAllocations.push(logonSid) - const worldSid = makeWellKnownSid(api, abi.WinWorldSid) - this.sidAllocations.push(worldSid) const writeSids = [this.writeSidPtr, this.tempWriteSidPtr].filter((sid): sid is NativePtr => sid !== undefined) restrictedToken = createRestrictedToken( api, currentToken, logonSid, writeSids, { world: worldSid }, this.mode, ) + restrictTokenIntegrity(api, restrictedToken, lowLabelSid) this.token = restrictedToken // The restricted token's default DACL still names only the user's // ambient SIDs — none of the restricting SIDs. Every NEW object the diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index f1f2befbdd..d78bac049d 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -144,6 +144,26 @@ export function setTokenDefaultDaclGrant(api: Win32Bindings, token: NativePtr, s api.localFree(newDacl) } +/** + * Lower the restricted token's integrity level to Low (S-1-16-4096), the level + * the mandatory labels `grantWrite` applies are matched against; a token left + * at Medium would ignore them. Requires TOKEN_ADJUST_DEFAULT on the token; + * fails closed before any child is spawned. + * @param api - the binding table. + * @param token - the restricted token to lower. + * @param lowLabelSidPtr - the Low integrity SID (S-1-16-4096). + */ +export function restrictTokenIntegrity(api: Win32Bindings, token: NativePtr, lowLabelSidPtr: NativePtr): void { + const sidLength = api.getLengthSid(lowLabelSidPtr) + if (sidLength === 0) throwLastError(api, 'GetLengthSid', 'Low integrity label SID') + const info = Buffer.alloc(abi.TOKEN_MANDATORY_LABEL_SIZE + sidLength) + info.writeBigUInt64LE(ptrAddress(lowLabelSidPtr), 0) // Label.Sid + info.writeUInt32LE(abi.SE_GROUP_INTEGRITY, 8) // Label.Attributes + if (api.setTokenInformation(token, abi.TokenIntegrityLevel, info, info.length) === 0) { + throwLastError(api, 'SetTokenInformation', 'TokenIntegrityLevel (Low)') + } +} + /** Pack `SID_AND_ATTRIBUTES[count]` (16-byte stride; Attributes stay 0). */ function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { const buffer = Buffer.alloc(abi.SID_AND_ATTRIBUTES_SIZE * sids.length) diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 478fc71e1d..2968ff5b1b 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -42,6 +42,33 @@ export const TokenGroups = 2 export const TokenDefaultDacl = 6 /** SECURITY_INFORMATION flag selecting the DACL. */ export const DACL_SECURITY_INFORMATION = 0x00000004 +/** SECURITY_INFORMATION flag selecting the mandatory integrity label. */ +export const LABEL_SECURITY_INFORMATION = 0x00000010 +/** ACE type carrying a mandatory integrity label. */ +export const SYSTEM_MANDATORY_LABEL_ACE_TYPE = 0x11 +/** + * Mandatory policy denying write-class access to higher-integrity objects. + * The kernel applies it inside the access check, so it also covers writes and + * deletes granted through a parent directory's FILE_DELETE_CHILD right — + * the path the write-restricted pass-2 intersection does not reach. + */ +export const SYSTEM_MANDATORY_LABEL_NO_WRITE_UP = 0x00000001 +/** TOKEN_INFORMATION_CLASS value for the token's integrity level. */ +export const TokenIntegrityLevel = 25 +/** Group attribute marking the integrity SID of a TOKEN_MANDATORY_LABEL. */ +export const SE_GROUP_INTEGRITY = 0x00000020 +/** WELL_KNOWN_SID_TYPE value for the Low mandatory level (S-1-16-4096). */ +export const WinLowLabelSid = 66 +/** x64 TOKEN_MANDATORY_LABEL byte size (the SID is referenced, not embedded). */ +export const TOKEN_MANDATORY_LABEL_SIZE = 16 +/** x64 ACL header byte size (AclRevision, Sbz1, AclSize, AceCount, Sbz2). */ +export const ACL_HEADER_SIZE = 8 +/** Bytes a SYSTEM_MANDATORY_LABEL_ACE occupies beyond the ACL header and its SID. */ +export const MANDATORY_ACE_OVERHEAD = 8 +/** ACL revision accepted by InitializeAcl and AddMandatoryAce. */ +export const ACL_REVISION = 2 +/** LocalAlloc flag selecting zero-initialized fixed memory (LMEM_FIXED | LMEM_ZEROINIT). */ +export const LPTR = 0x0040 /** SE_OBJECT_TYPE value for filesystem objects. */ export const SE_FILE_OBJECT = 1 /** TRUSTEE_TYPE value used when trustee classification is unknown. */ @@ -52,10 +79,14 @@ export const TRUSTEE_IS_SID = 0 export const NO_MULTIPLE_TRUSTEE = 0 /** EXPLICIT_ACCESS mode that grants access. */ export const GRANT_ACCESS = 1 +/** EXPLICIT_ACCESS mode that denies access. */ +export const DENY_ACCESS = 3 /** EXPLICIT_ACCESS mode that revokes access. */ export const REVOKE_ACCESS = 4 /** ACE inheritance flags for child containers and objects. */ export const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3 +/** ACE inheritance flag for child containers only (directories; files do not inherit). */ +export const CONTAINER_INHERIT_ACE = 0x2 /** Legacy Win32 maximum path character count used by GetTempPathW. */ export const MAX_PATH = 260 /** Successful Win32 status code. */ @@ -80,6 +111,8 @@ export const LOCKFILE_EXCLUSIVE_LOCK = 0x2 export const LOCKFILE_FAIL_IMMEDIATELY = 0x1 /** ACE type for an allowed-access entry. */ export const ACCESS_ALLOWED_ACE_TYPE = 0 +/** ACE type for a denied-access entry (shares the allowed ACE's Mask/SID layout). */ +export const ACCESS_DENIED_ACE_TYPE = 1 /** Maximum SID sub-authority count. */ export const SID_MAX_SUB_AUTHORITIES = 15 /** ACE flag marking inherited entries. */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts index bcaee704ba..741e7e283f 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -1,16 +1,17 @@ /** * ACL failure-path tests with minimal stub binding tables: every checked - * Win32 call in the lock, read-merge-write, and + * Win32 call in the lock, read-merge-write, mandatory-label, and * grant-skip sequence has a failing counterpart, and each failure closes the - * handles it created before throwing. The exact-ACE skip and the DACL-walk - * defenses are driven through crafted in-memory ACL/SID buffers. Pure - * stubs — no real Win32 calls, so these run on every platform; the - * real-FFI round-trip lives in acl.spec.ts (win32 only). + * handles it created before throwing. The exact-ACE/exact-label skip and the + * DACL/SACL-walk defenses are driven through crafted in-memory ACL/SID + * buffers. Pure stubs — no real Win32 calls, so these run on every platform; + * the real-FFI round-trip lives in acl.spec.ts (win32 only). */ import { tmpdir } from 'node:os' import { Win32Error } from '@deepseek-ai/dsh-win32-process' import { describe, expect, it, vi } from 'vitest' +import type { Mock } from 'vitest' import koffi from 'koffi' import { grantWrite, revokeWrite, withPathLock } from '../src/acl.ts' @@ -34,9 +35,10 @@ function aclApi(overrides: Partial = {}): Win32Bindings { closeHandle: vi.fn(() => 1), getNamedSecurityInfoW: vi.fn(( _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, ) => { koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one + koffi.encode(sacl, PVOID, 0n) // no mandatory label either koffi.encode(descriptor, PVOID, 0n) return 0 }), @@ -45,6 +47,10 @@ function aclApi(overrides: Partial = {}): Win32Bindings { return 0 }), setNamedSecurityInfoW: vi.fn(() => 0), + localAlloc: vi.fn(() => 11n), + getLengthSid: vi.fn(() => 12), + initializeAcl: vi.fn(() => 1), + addMandatoryAce: vi.fn(() => 1), localFree: vi.fn(() => 0n as NativePtr), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), @@ -63,31 +69,109 @@ function craftSid(revision: number, count: number, authority: number[] = [0, 0, return sid } +/** The Low integrity SID both the token and the directory labels name. */ +function craftLowLabelSid(): NativePtr { + return craftSid(1, 0, [0, 0, 0, 0, 0, 16]) +} + +/** The world SID the grant's ambient-delete deny names (crafted like the other stubs: 8 header bytes, no sub-authorities). */ +function craftWorldSid(): NativePtr { + return craftSid(1, 0, [0, 0, 0, 0, 0, 1]) +} + +/** Write one 16-byte ACE (AceType@0, AceFlags@1, AceSize@2, Mask@4, inline SID@8) at `offset`. */ +function writeAce( + acl: NativePtr, + offset: number, + aceType: number, + mask: number, + sid: NativePtr, + match: boolean, + inheritance: number = abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, +): void { + koffi.encode(acl, offset + 0, 'uint8', aceType) + koffi.encode(acl, offset + 1, 'uint8', inheritance) + koffi.encode(acl, offset + 2, 'uint16', 16) + koffi.encode(acl, offset + 4, 'uint32', mask) + for (let byte = 0; byte < 8; byte++) { + koffi.encode(acl, offset + 8 + byte, 'uint8', match + ? koffi.decode(sid, byte, 'uint8') as number + : byte === 0 ? 9 : 0) + } +} + /** - * One in-memory ACL carrying the exact grant ACE the skip checks for: - * header (AclRevision@0, AclSize@2, AceCount@4) then one ACCESS_ALLOWED_ACE - * (AceType@0, AceFlags@1, AceSize@2, Mask@4, inline SID@8). `match` selects - * whether the inline SID bytes equal `sid`. + * One in-memory ACL carrying a single inheritable ACE: header (AclRevision@0, + * AclSize@2, AceCount@4) then a 16-byte ACE. `match` selects whether the + * inline SID bytes equal `sid`. */ -function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr { +function craftAcl(aceType: number, mask: number, sid: NativePtr, match: boolean): NativePtr { const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) // AclRevision - koffi.encode(acl, 2, 'uint16', 24) // AclSize: 8-byte header + one 16-byte ACE - koffi.encode(acl, 4, 'uint16', 1) // AceCount - const ace = 8 - koffi.encode(acl, ace + 0, 'uint8', abi.ACCESS_ALLOWED_ACE_TYPE) - koffi.encode(acl, ace + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT) - koffi.encode(acl, ace + 2, 'uint16', 16) // AceSize: header + mask + inline 8-byte SID - koffi.encode(acl, ace + 4, 'uint32', abi.GRANT_MASK) - const inlineSid = ace + 8 - for (let offset = 0; offset < 8; offset++) { - koffi.encode(acl, inlineSid + offset, 'uint8', match - ? koffi.decode(sid, offset, 'uint8') as number - : offset === 0 ? 9 : 0) - } + koffi.encode(acl, 2, 'uint16', 24) + koffi.encode(acl, 4, 'uint16', 1) + writeAce(acl, 8, aceType, mask, sid, match) return acl } +/** + * A two-ACE DACL: the ambient-delete deny plus the capability grant, each with + * caller-chosen type, mask, and trustee match so the skip's field checks can be + * driven one at a time. + */ +function craftPair( + grantSid: NativePtr, + world: NativePtr, + denyType: number, + denyMask: number, + denyMatches: boolean, + grantMatches: boolean, +): NativePtr { + const acl = allocBytes(64) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 40) + koffi.encode(acl, 4, 'uint16', 2) + writeAce(acl, 8, denyType, denyMask, world, denyMatches, abi.CONTAINER_INHERIT_ACE) + writeAce(acl, 24, abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, grantSid, grantMatches) + return acl +} + +/** + * The DACL a fully granted directory carries: the ambient-delete deny for the + * world SID plus the capability grant. `grantMatches` selects whether the + * grant's inline SID equals `sid` (the deny always matches), and + * `includeDeny` drops the deny to model a root granted by an earlier build. + */ +function craftGrantedAcl(sid: NativePtr, world: NativePtr, grantMatches: boolean, includeDeny = true): NativePtr { + if (!includeDeny) { + const acl = allocBytes(64) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 24) + koffi.encode(acl, 4, 'uint16', 1) + writeAce(acl, 8, abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sid, grantMatches) + return acl + } + return craftPair(sid, world, abi.ACCESS_DENIED_ACE_TYPE, abi.FILE_DELETE_CHILD, true, grantMatches) +} + +/** The label ACL the exact-label skip looks for: the Low no-write-up label ACE. */ +function craftAclWithLabel(sid: NativePtr, match: boolean): NativePtr { + return craftAcl(abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE, abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, sid, match) +} + +/** Encode a security-info read whose DACL and label ACL are the given pointers. */ +function readStub(dacl: NativePtr | null, label: NativePtr | null, descriptor: bigint | null) { + return vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + daclSlot: NativePtr, saclSlot: NativePtr, descriptorSlot: NativePtr, + ) => { + koffi.encode(daclSlot, PVOID, dacl === null ? 0n : ptrAddress(dacl)) + koffi.encode(saclSlot, PVOID, label === null ? 0n : ptrAddress(label)) + koffi.encode(descriptorSlot, PVOID, descriptor ?? 0n) + return 0 + }) +} + describe('withPathLock failure paths', () => { it('fails closed when CreateFileW returns an invalid handle', () => { const api = aclApi({ createFileW: vi.fn(() => 0n as NativePtr) }) @@ -142,13 +226,67 @@ describe('withPathLock failure paths', () => { }) }) +describe('buildLowLabelAcl failure paths', () => { + it('reports a failed GetLengthSid for the Low label SID', () => { + const api = aclApi({ getLengthSid: vi.fn(() => 0) }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetLengthSid') + }) + + it('reports a failed LocalAlloc for the label ACL', () => { + const api = aclApi({ localAlloc: vi.fn(() => 0n as NativePtr) }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalAlloc') + }) + + it('frees the label ACL and reports when InitializeAcl fails', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ initializeAcl: vi.fn(() => 0), localFree }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('InitializeAcl') + expect(localFree).toHaveBeenCalledWith(11n) + }) + + it('frees the label ACL and reports when AddMandatoryAce fails', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ addMandatoryAce: vi.fn(() => 0), localFree }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('AddMandatoryAce') + expect(localFree).toHaveBeenCalledWith(11n) + }) +}) + describe('mergeAndApply failure paths', () => { it('reports a SetEntriesInAclW failure when the directory carries no descriptor to free', () => { const api = aclApi({ setEntriesInAclW: vi.fn(() => 5) }) // default descriptor: none const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -161,7 +299,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -172,21 +310,14 @@ describe('mergeAndApply failure paths', () => { it('frees the descriptor and reports when SetEntriesInAclW fails', () => { const localFree = vi.fn(() => 0n as NativePtr) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, 0n) - koffi.encode(descriptor, PVOID, 6n) // an existing explicit DACL - return 0 - }), + getNamedSecurityInfoW: readStub(null, null, 6n), // an existing descriptor without a DACL setEntriesInAclW: vi.fn(() => 5), localFree, }) const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -198,21 +329,14 @@ describe('mergeAndApply failure paths', () => { it('frees the descriptor and reports a NULL merged ACL', () => { const localFree = vi.fn(() => 0n as NativePtr) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, 0n) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(null, null, 6n), setEntriesInAclW: vi.fn(() => 0), // success without writing the out slot localFree, }) const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -221,37 +345,31 @@ describe('mergeAndApply failure paths', () => { expect(localFree).toHaveBeenCalledWith(6n) }) - it('frees the merged ACL and reports when SetNamedSecurityInfoW fails', () => { + it('frees the merged ACL and the label ACL and reports when SetNamedSecurityInfoW fails', () => { const localFree = vi.fn(() => 0n as NativePtr) const api = aclApi({ setNamedSecurityInfoW: vi.fn(() => 5), localFree }) const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } expect(caught).toBeInstanceOf(Win32Error) expect((caught as Win32Error).api).toBe('SetNamedSecurityInfoW') - expect(localFree).toHaveBeenCalledWith(9n) + expect(localFree).toHaveBeenCalledWith(9n) // merged DACL + expect(localFree).toHaveBeenCalledWith(11n) // label ACL }) it('reports a failed descriptor LocalFree after a successful apply', () => { const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, 0n) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(null, null, 6n), localFree: vi.fn(() => 1n as NativePtr), // both frees "fail"; the first is checked }) const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -266,74 +384,238 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } expect(caught).toBeInstanceOf(Win32Error) expect((caught as Win32Error).api).toBe('LocalFree') }) + + it('reports a failed label-ACL LocalFree after a successful apply', () => { + // Every free succeeds until the label ACL's: descriptor and merged ACL + // return null, the label ACL returns a stale pointer. + const localFree = vi.fn() + .mockReturnValueOnce(0n) + .mockReturnValueOnce(0n) + .mockReturnValue(1n) + const api = aclApi({ + getNamedSecurityInfoW: readStub(null, null, 6n), + localFree, + }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalFree') + expect(localFree).toHaveBeenLastCalledWith(11n) + }) + + it('frees the label ACL when SetEntriesInAclW fails during a revoke', () => { + // The revoke carries no label ACL of its own: the early exit must still + // release the descriptor and leave the label edit untouched. + const localFree = vi.fn(() => 0n as NativePtr) + const sid = craftSid(1, 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftAcl(abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sid, true), null, 6n), + setEntriesInAclW: vi.fn(() => 5), + localFree, + }) + let caught: unknown + try { + revokeWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('frees the descriptor on the null-merged-ACL path during a revoke', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const sid = craftSid(1, 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftAcl(abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sid, true), null, 6n), + setEntriesInAclW: vi.fn(() => 0), // success without writing the out slot + localFree, + }) + let caught: unknown + try { + revokeWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('frees the descriptor when the label ACL build fails', () => { + // The read already owns a descriptor allocation; the label failure must + // not strand it. + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ + getNamedSecurityInfoW: readStub(null, null, 6n), + initializeAcl: vi.fn(() => 0), + localFree, + }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('InitializeAcl') + expect(localFree).toHaveBeenCalledWith(11n) // the half-built label ACL + expect(localFree).toHaveBeenCalledWith(6n) // the read descriptor + }) + + it('frees the label ACL when SetEntriesInAclW fails', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ setEntriesInAclW: vi.fn(() => 5), localFree }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(11n) // the label ACL this apply owns + }) + + it('frees the label ACL and the descriptor on the null-merged-ACL path', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ + getNamedSecurityInfoW: readStub(null, null, 6n), + setEntriesInAclW: vi.fn(() => 0), // success without writing the out slot + localFree, + }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(6n) + expect(localFree).toHaveBeenCalledWith(11n) + }) }) -describe('the exact-ACE skip and DACL-walk defenses', () => { - it('grantWrite skips the apply when the standing exact ACE matches (descriptor freed, nothing merged)', () => { +describe('revokeWrite label handling', () => { + /** The SECURITY_INFORMATION and SACL of the last apply. */ + function applyArgs(setNamedSecurityInfoW: Mock): { information: number; sacl: unknown } { + const call = setNamedSecurityInfoW.mock.calls.at(-1) + return { information: call?.[2] as number, sacl: call?.[6] } + } + + it('keeps the shared Low label while another capability grant stands', () => { const sid = craftSid(1, 0) + const otherSid = craftSid(1, 0, [0, 0, 0, 0, 0, 6]) + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + // The directory carries the deny plus a grant for a DIFFERENT SID. + getNamedSecurityInfoW: readStub( + craftPair(otherSid, world, abi.ACCESS_DENIED_ACE_TYPE, abi.FILE_DELETE_CHILD, true, true), null, 6n, + ), + setNamedSecurityInfoW, + }) + expect(revokeWrite(api, 'C:\\granted', sid)).toBe(true) + const { information, sacl } = applyArgs(setNamedSecurityInfoW) + expect(information & abi.LABEL_SECURITY_INFORMATION).toBe(0) // the label was left untouched + expect(sacl).toBeNull() + }) + + it('clears the Low label once the last capability grant is revoked', () => { + const sid = craftSid(1, 0) + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), null, 6n), + setNamedSecurityInfoW, + }) + expect(revokeWrite(api, 'C:\\granted', sid)).toBe(true) + expect(applyArgs(setNamedSecurityInfoW).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) + }) + + it('clears the Low label when a malformed tiny DACL hides any foreign grant', () => { + const acl = allocBytes(32) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 4) // smaller than the ACL header + koffi.encode(acl, 4, 'uint16', 1) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ getNamedSecurityInfoW: readStub(acl, null, 6n), setNamedSecurityInfoW }) + expect(revokeWrite(api, 'C:\\granted', craftSid(1, 0))).toBe(true) + expect(applyArgs(setNamedSecurityInfoW).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) + }) + + it('clears the Low label when a lying ACE size hides any foreign grant', () => { + const acl = allocBytes(32) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 8) + koffi.encode(acl, 4, 'uint16', 1) + koffi.encode(acl, 10, 'uint16', 100) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ getNamedSecurityInfoW: readStub(acl, null, 6n), setNamedSecurityInfoW }) + expect(revokeWrite(api, 'C:\\granted', craftSid(1, 0))).toBe(true) + expect(applyArgs(setNamedSecurityInfoW).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) + }) +}) + +describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { + it('grantWrite skips the apply when the standing exact ACE and the exact label match (descriptor freed, nothing merged)', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() const localFree = vi.fn(() => 0n as NativePtr) const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), craftAclWithLabel(lowSid, true), 6n), localFree, setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(localFree).toHaveBeenCalledWith(6n) }) - it('grantWrite skips the apply without freeing when the exact ACE stands but no descriptor owns it', () => { + it('grantWrite skips the apply without freeing when the exact ACE and label stand but no descriptor owns them', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() const localFree = vi.fn(() => 0n as NativePtr) const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) - koffi.encode(descriptor, PVOID, 0n) // the read "returned" a bare ACL with no descriptor - return 0 - }), + // the read "returned" ACLs with no descriptor allocation of their own + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), craftAclWithLabel(lowSid, true), null), localFree, setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(localFree).not.toHaveBeenCalled() }) - it('grantWrite reports a failed descriptor LocalFree on the exact-ACE skip path', () => { + it('grantWrite reports a failed descriptor LocalFree on the exact-ACE/exact-label skip path', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), craftAclWithLabel(lowSid, true), 6n), localFree: vi.fn(() => 1n as NativePtr), }) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) } catch (error) { caught = error } @@ -341,48 +623,124 @@ describe('the exact-ACE skip and DACL-walk defenses', () => { expect((caught as Win32Error).api).toBe('LocalFree') }) - it('falls back to the merge path when the standing ACE names a different SID', () => { + it('does not skip when a root granted by an earlier build carries the ACE and label but no ambient-delete deny', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, false))) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true, false), craftAclWithLabel(lowSid, true), 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not skip when the deny names another trustee', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub( + craftPair(sid, world, abi.ACCESS_DENIED_ACE_TYPE, abi.FILE_DELETE_CHILD, false, true), craftAclWithLabel(lowSid, true), 6n, + ), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not treat a deny of another right as the ambient-delete deny', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub( + craftPair(sid, world, abi.ACCESS_DENIED_ACE_TYPE, abi.GRANT_MASK, true, true), craftAclWithLabel(lowSid, true), 6n, + ), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not treat an Allow ACE as the ambient-delete deny', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub( + craftPair(sid, world, abi.ACCESS_ALLOWED_ACE_TYPE, abi.FILE_DELETE_CHILD, true, true), craftAclWithLabel(lowSid, true), 6n, + ), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not skip when the standing ACE and label match but the label ACL is absent', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), null, 6n), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not skip when the exact ACE stands but the label names another integrity level', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), craftAclWithLabel(lowSid, false), 6n), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('falls back to the merge path when the standing ACE names a different SID', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, false), craftAclWithLabel(lowSid, true), 6n), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('treats an implausibly small ACL size as no exact grant', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) koffi.encode(acl, 2, 'uint16', 4) // smaller than the 8-byte ACL header koffi.encode(acl, 4, 'uint16', 1) const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(acl)) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(acl, craftAclWithLabel(lowSid, true), 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('treats an ACE that would overrun the ACL as no exact grant', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) koffi.encode(acl, 2, 'uint16', 8) // header only: no room for any ACE @@ -390,17 +748,80 @@ describe('the exact-ACE skip and DACL-walk defenses', () => { koffi.encode(acl, 10, 'uint16', 100) // the walk reads a lying ACE size const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(acl)) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(acl, craftAclWithLabel(lowSid, true), 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('treats an implausibly small label ACL size as no exact label', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const label = allocBytes(32) + koffi.encode(label, 'uint8', 2) + koffi.encode(label, 2, 'uint16', 4) // smaller than the 8-byte ACL header + koffi.encode(label, 4, 'uint16', 1) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), label, 6n), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('treats a label ACE that would overrun its ACL as no exact label', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const label = allocBytes(32) + koffi.encode(label, 'uint8', 2) + koffi.encode(label, 2, 'uint16', 8) // header only: no room for any ACE + koffi.encode(label, 4, 'uint16', 1) + koffi.encode(label, 10, 'uint16', 100) // the walk reads a lying ACE size + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), label, 6n), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not treat a no-write-up ACL Allow ACE as the mandatory label', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + // Same mask and SID, but an ACCESS_ALLOWED_ACE is not a mandatory label. + getNamedSecurityInfoW: readStub( + craftGrantedAcl(sid, world, true), + craftAcl(abi.ACCESS_ALLOWED_ACE_TYPE, abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, lowSid, true), + 6n, + ), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not treat a label ACE granting write-up as the exact label', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub( + craftGrantedAcl(sid, world, true), + craftAcl(abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE, 0, lowSid, true), + 6n, + ), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) }) @@ -416,14 +837,7 @@ describe('revokeWrite no-DACL path', () => { it('frees a descriptor that carries no DACL and reports nothing to revoke', () => { const localFree = vi.fn(() => 0n as NativePtr) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, 0n) - koffi.encode(descriptor, PVOID, 6n) // descriptor WITHOUT a DACL - return 0 - }), + getNamedSecurityInfoW: readStub(null, null, 6n), // descriptor WITHOUT a DACL localFree, }) const sid = craftSid(1, 0) @@ -433,14 +847,7 @@ describe('revokeWrite no-DACL path', () => { it('reports a failed descriptor LocalFree on the no-DACL path', () => { const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, 0n) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(null, null, 6n), localFree: vi.fn(() => 1n as NativePtr), }) const sid = craftSid(1, 0) diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 8d548a16df..3bf86f4d43 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -9,7 +9,7 @@ * whose per-test lock file is removed in cleanup. */ -import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -17,7 +17,7 @@ import koffi from 'koffi' import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts' import { AclSandbox } from '../src/index.ts' -import { createRestrictedToken } from '../src/token.ts' +import { createRestrictedToken, makeWellKnownSid } from '../src/token.ts' import { allocOverlapped, allocPtrSlot, decodePtr, isInvalidHandle, isNullPtr, win32 } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import * as abi from '../src/win32-abi.ts' @@ -104,6 +104,104 @@ function readDirectAces(api: Win32Bindings, path: string): DirectAce[] { } } +/** One direct (explicit, non-inherited) label ACE of a directory's mandatory label. */ +interface DirectLabelAce { + sid: string + mask: number +} + +/** The Low integrity SID every grant labels with (S-1-16-4096). */ +function lowLabelSid(api: Win32Bindings): NativePtr { + return makeWellKnownSid(api, abi.WinLowLabelSid) +} + +/** The world SID (S-1-1-0) every grant denies the ambient delete right to. */ +function worldSid(api: Win32Bindings): NativePtr { + return makeWellKnownSid(api, abi.WinWorldSid) +} + +/** + * Read the directory's mandatory label ACEs (inherited entries excluded) — + * same ACE layout as {@link readDirectAces}, read through the label security + * information class. A directory with no label returns an empty list. + */ +function readLabelAces(api: Win32Bindings, path: string): DirectLabelAce[] { + const ownerSlot = allocPtrSlot() + const groupSlot = allocPtrSlot() + const daclSlot = allocPtrSlot() + const saclSlot = allocPtrSlot() + const descriptorSlot = allocPtrSlot() + const readResult = api.getNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.LABEL_SECURITY_INFORMATION, + ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, + ) + if (readResult !== abi.ERROR_SUCCESS) throw new Error(`GetNamedSecurityInfoW (label) failed (${readResult}) for ${path}`) + const acl = decodePtr(saclSlot) + const descriptor = decodePtr(descriptorSlot) + try { + if (acl === null) return [] + const aclSize = koffi.decode(acl, 2, 'uint16') as number + const aces: DirectLabelAce[] = [] + for (let offset = 8; offset + 8 <= aclSize;) { + const type = koffi.decode(acl, offset, 'uint8') as number + const flags = koffi.decode(acl, offset + 1, 'uint8') as number + const aceSize = koffi.decode(acl, offset + 2, 'uint16') as number + if (type === abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE && (flags & abi.INHERITED_ACE) === 0) { + aces.push({ sid: sidString(koffi.decode(acl, offset + 8, SID_STRUCT) as SidLayout), mask: koffi.decode(acl, offset + 4, 'uint32') as number }) + } + offset += aceSize + } + return aces + } finally { + if (descriptor !== null) api.localFree(descriptor) + } +} + +/** One ACE of a directory DACL, inherited or not. */ +interface TypedAce { + sid: string + mask: number + type: number + flags: number +} + +/** + * Read every ACE of the object's DACL (inherited ones included) — the shape + * needed to observe what a grant propagated onto children. + */ +function readTypedAces(api: Win32Bindings, path: string): TypedAce[] { + const ownerSlot = allocPtrSlot() + const groupSlot = allocPtrSlot() + const daclSlot = allocPtrSlot() + const saclSlot = allocPtrSlot() + const descriptorSlot = allocPtrSlot() + const readResult = api.getNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, + ) + if (readResult !== abi.ERROR_SUCCESS) throw new Error(`GetNamedSecurityInfoW failed (${readResult}) for ${path}`) + const acl = decodePtr(daclSlot) + const descriptor = decodePtr(descriptorSlot) + try { + if (acl === null) return [] + const aclSize = koffi.decode(acl, 2, 'uint16') as number + const aces: TypedAce[] = [] + for (let offset = 8; offset + 8 <= aclSize;) { + const aceSize = koffi.decode(acl, offset + 2, 'uint16') as number + aces.push({ + sid: sidString(koffi.decode(acl, offset + 8, SID_STRUCT) as SidLayout), + mask: koffi.decode(acl, offset + 4, 'uint32') as number, + type: koffi.decode(acl, offset, 'uint8') as number, + flags: koffi.decode(acl, offset + 1, 'uint8') as number, + }) + offset += aceSize + } + return aces + } finally { + if (descriptor !== null) api.localFree(descriptor) + } +} + describe.skipIf(!isWin32)('ACL editing', () => { const scratchDirs: string[] = [] afterEach(() => { @@ -116,11 +214,66 @@ describe.skipIf(!isWin32)('ACL editing', () => { return dir } + it('the ambient-delete deny inherits to subdirectories only, never onto files', async () => { + // FILE_DELETE_CHILD is meaningless on a file and 0x40 is part of + // FILE_ALL_ACCESS, so a file-level copy would deny every FullControl open + // inside the granted root. + const api = await win32() + const dir = scratch() + const capabilitySid = sidFromString(api, 'S-1-4-4242-11') + const lowSid = lowLabelSid(api) + const world = worldSid(api) + const childDir = join(dir, 'child') + const childFile = join(dir, 'child.txt') + try { + grantWrite(api, dir, capabilitySid, lowSid, world) + mkdirSync(childDir) + writeFileSync(childFile, 'x') + const isWorldDeny = (ace: TypedAce): boolean => + ace.type === abi.ACCESS_DENIED_ACE_TYPE && ace.sid === 'S-1-1-0' && ace.mask === abi.FILE_DELETE_CHILD + expect(readTypedAces(api, dir).filter(isWorldDeny)).toHaveLength(1) // explicit on the root + expect(readTypedAces(api, childDir).filter(isWorldDeny)).toHaveLength(1) // inherited by the container + expect(readTypedAces(api, childFile).filter(isWorldDeny)).toHaveLength(0) // never by the file + const rootDeny = readTypedAces(api, dir).find(isWorldDeny) + expect(rootDeny?.flags).toBe(abi.CONTAINER_INHERIT_ACE) + } finally { + if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) + if (!isNullPtr(lowSid)) api.localFree(lowSid) + if (!isNullPtr(world)) api.localFree(world) + } + }) + + it('revoking one of two grants on a directory keeps the shared Low label', async () => { + const api = await win32() + const dir = scratch() + const sidA = sidFromString(api, 'S-1-4-4242-21') + const sidB = sidFromString(api, 'S-1-4-4242-22') + const lowSid = lowLabelSid(api) + const world = worldSid(api) + try { + grantWrite(api, dir, sidA, lowSid, world) + grantWrite(api, dir, sidB, lowSid, world) + revokeWrite(api, dir, sidA) + expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-21')).toBe(false) + expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-22')).toBe(true) + expect(readLabelAces(api, dir)).toHaveLength(1) // the surviving grant still needs it + revokeWrite(api, dir, sidB) + expect(readLabelAces(api, dir)).toEqual([]) // the last revoke clears it + } finally { + if (!isNullPtr(sidA)) api.localFree(sidA) + if (!isNullPtr(sidB)) api.localFree(sidB) + if (!isNullPtr(lowSid)) api.localFree(lowSid) + if (!isNullPtr(world)) api.localFree(world) + } + }) + it('grantWrite merges into the current DACL: an explicit Users ACE survives grant+revoke', async () => { const api = await win32() const dir = scratch() const usersSid = sidFromString(api, 'S-1-5-32-545') const capabilitySid = sidFromString(api, 'S-1-4-4242-1') + const lowSid = lowLabelSid(api) + const world = worldSid(api) try { // Install one explicit ACE (Users + benign read mask) with the // package's own bindings, exactly like a pre-existing explicit DACL @@ -137,37 +290,77 @@ describe.skipIf(!isWin32)('ACL editing', () => { expect(applyResult, `SetNamedSecurityInfoW setup (${applyResult})`).toBe(abi.ERROR_SUCCESS) expect(isNullPtr(freed)).toBe(true) - grantWrite(api, dir, capabilitySid) + grantWrite(api, dir, capabilitySid, lowSid, world) + + const granted = readDirectAces(api, dir) + expect(granted.some(ace => ace.sid === 'S-1-5-32-545')).toBe(true) // explicit ACE preserved + expect(granted.some(ace => ace.sid === 'S-1-4-4242-1')).toBe(true) + revokeWrite(api, dir, capabilitySid) const aces = readDirectAces(api, dir) expect(aces.some(ace => ace.sid === 'S-1-5-32-545')).toBe(true) // explicit ACE preserved expect(aces.some(ace => ace.sid === 'S-1-4-4242-1')).toBe(false) // orphan grant fully removed + expect(readLabelAces(api, dir)).toEqual([]) // the label went with it } finally { if (!isNullPtr(usersSid)) api.localFree(usersSid) if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) + if (!isNullPtr(lowSid)) api.localFree(lowSid) + if (!isNullPtr(world)) api.localFree(world) } }) - it('grantWrite is idempotent: a second grant over the standing exact ACE skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { + it('grantWrite applies the Low no-write-up label and denies the ambient parent-delete right; revokeWrite clears both capability and label', async () => { + const api = await win32() + const dir = scratch() + const capabilitySid = sidFromString(api, 'S-1-4-4242-9') + const lowSid = lowLabelSid(api) + const world = worldSid(api) + try { + expect(readLabelAces(api, dir)).toEqual([]) + grantWrite(api, dir, capabilitySid, lowSid, world) + expect(readLabelAces(api, dir)).toEqual([ + { sid: 'S-1-16-4096', mask: abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP }, + ]) + // The ambient delete route inside another granted root is what the deny + // removes; the capability ACE stays the only delete authority there. + const denied = readDirectAces(api, dir).filter(ace => ace.sid === 'S-1-1-0') + expect(denied).toEqual([{ sid: 'S-1-1-0', mask: abi.FILE_DELETE_CHILD }]) + revokeWrite(api, dir, capabilitySid) + expect(readLabelAces(api, dir)).toEqual([]) + expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-9')).toBe(false) + } finally { + if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) + if (!isNullPtr(lowSid)) api.localFree(lowSid) + if (!isNullPtr(world)) api.localFree(world) + } + }) + + it('grantWrite is idempotent: a second grant over the standing exact ACE, deny, and label skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { const api = await win32() const dir = scratch() const capabilitySid = sidFromString(api, 'S-1-4-4242-2') + const lowSid = lowLabelSid(api) + const world = worldSid(api) const apply = vi.spyOn(api, 'setNamedSecurityInfoW') try { - grantWrite(api, dir, capabilitySid) + grantWrite(api, dir, capabilitySid, lowSid, world) expect(apply).toHaveBeenCalledTimes(1) - // The exact ACE now stands (the per-session grant surviving from a - // previous server lifetime): the second grant is a DACL read only. - grantWrite(api, dir, capabilitySid) + // The exact ACE, deny, and label now stand (the per-session grant + // surviving from a previous server lifetime): the second grant is a read only. + grantWrite(api, dir, capabilitySid, lowSid, world) expect(apply).toHaveBeenCalledTimes(1) const aces = readDirectAces(api, dir) expect(aces.filter(ace => ace.sid === 'S-1-4-4242-2')).toHaveLength(1) + expect(aces.filter(ace => ace.sid === 'S-1-1-0')).toHaveLength(1) + expect(readLabelAces(api, dir)).toHaveLength(1) revokeWrite(api, dir, capabilitySid) expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-2')).toBe(false) } finally { apply.mockRestore() if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) + if (!isNullPtr(lowSid)) api.localFree(lowSid) + if (!isNullPtr(world)) api.localFree(world) } }) @@ -188,7 +381,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(true) }) - it('dispose revokes the revocable temp ACE and keeps the standing workspace ACE (self-managed flow)', async () => { + it('dispose revokes the revocable temp ACE and label and keeps the standing workspace pair (self-managed flow)', async () => { const api = await win32() const workspaceDir = scratch() const tempDir = scratch() @@ -203,8 +396,12 @@ describe.skipIf(!isWin32)('ACL editing', () => { sandbox.dispose() const workspaceAces = readDirectAces(api, workspaceDir) expect(workspaceAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(true) + expect(readLabelAces(api, workspaceDir)).toEqual([ + { sid: 'S-1-16-4096', mask: abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP }, + ]) const tempAces = readDirectAces(api, tempDir) expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3-1')).toBe(false) + expect(readLabelAces(api, tempDir)).toEqual([]) }) it('rejects an overlapping private temp directory before applying either capability', async () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts index e22a46060c..208110863e 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts @@ -15,29 +15,41 @@ import { AclWriteGrant } from '../src/index.ts' const PVOID = koffi.pointer('void') +/** Stub binding table: only the members a test drives, so the rest are never called. */ +function stubBindings(overrides: Partial): Win32Bindings { + return overrides as Win32Bindings +} + /** The stub the grant-then-fail-revoke sequence needs: every call succeeds until the DACL read is flipped off. */ function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { const state = { failReads: false } - const api = { + const api = stubBindings({ convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { koffi.encode(slot, PVOID, 42n) return 1 }), + createWellKnownSid: vi.fn(() => 1), + isValidSid: vi.fn(() => 1), + getLengthSid: vi.fn(() => 12), + localAlloc: vi.fn(() => 11n as NativePtr), + initializeAcl: vi.fn(() => 1), + addMandatoryAce: vi.fn(() => 1), getTempPathW: vi.fn((_length: number, buffer: Buffer) => { const temp = tmpdir().endsWith('/') || tmpdir().endsWith('\\') ? tmpdir() : `${tmpdir()}/` buffer.write(temp, 'utf16le') return temp.length }), - createFileW: vi.fn(() => 7n), + createFileW: vi.fn(() => 7n as NativePtr), lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1), closeHandle: vi.fn(() => 1), getNamedSecurityInfoW: vi.fn(( _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, ) => { if (state.failReads) return 2 // ERROR_FILE_NOT_FOUND — the revoke's read fails koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one + koffi.encode(sacl, PVOID, 0n) koffi.encode(descriptor, PVOID, 0n) return 0 }), @@ -46,10 +58,10 @@ function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { return 0 }), setNamedSecurityInfoW: vi.fn(() => 0), - localFree: vi.fn(() => 0n), + localFree: vi.fn(() => 0n as NativePtr), getLastError: vi.fn(() => 2), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + }) return { api, failReads: () => { state.failReads = true } } } @@ -85,16 +97,53 @@ describe('AclWriteGrant failure paths', () => { }) it('dispose aggregates a failing SID free into an AggregateError', () => { - const api = { + const api = stubBindings({ convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { koffi.encode(slot, PVOID, 42n) return 1 }), - localFree: vi.fn(() => 1n), // non-NULL: LocalFree "failed" + createWellKnownSid: vi.fn(() => 1), + isValidSid: vi.fn(() => 1), + localFree: vi.fn(() => 1n as NativePtr), // non-NULL: LocalFree "failed" getLastError: vi.fn(() => 87), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + }) const grant = AclWriteGrant.create('S-1-4-42-42', api) expect(() =>{ grant.dispose() }).toThrow(AggregateError) }) + + it('create fails closed when the Low label SID cannot be created', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = stubBindings({ + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 42n) + return 1 + }), + createWellKnownSid: vi.fn(() => 0), + localFree, + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + }) + expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/CreateWellKnownSid/) + expect(localFree).toHaveBeenCalledWith(42n) // the parsed capability SID is not stranded + }) + + it('create frees both earlier SIDs when the world SID cannot be created', () => { + const localFree = vi.fn(() => 0n as NativePtr) + let calls = 0 + const api = stubBindings({ + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 42n) + return 1 + }), + createWellKnownSid: vi.fn(() => (++calls === 1 ? 1 : 0)), // the Low label SID succeeds, the world SID fails + isValidSid: vi.fn(() => 1), + localFree, + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + }) + expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/CreateWellKnownSid/) + expect(localFree).toHaveBeenCalledTimes(2) + expect(localFree).toHaveBeenCalledWith(42n) + }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 50a4a9987e..64aac56080 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -35,6 +35,8 @@ interface HappyStubs { createRestrictedToken: MockFn createJobObjectW: MockFn getNamedSecurityInfoW: MockFn + setTokenInformation: MockFn + addMandatoryAce: MockFn } const state = vi.hoisted(() => ({ stubs: undefined as HappyStubs | undefined })) @@ -59,6 +61,11 @@ function scratch(): string { return dir } +/** Stub binding table: only the members a test drives, so the rest are never called. */ +function stubBindings(overrides: Partial): Win32Bindings { + return overrides as Win32Bindings +} + /** * The stub the whole happy pipeline needs: token opening, capability-SID * parsing, workspace+temp grants, logon-SID scan, well-known SID, restricted token, @@ -67,7 +74,7 @@ function scratch(): string { */ function happyStubs(): HappyStubs { let next = 0n - const fresh = () => ++next + const fresh = (): NativePtr => ++next as NativePtr const openProcess = vi.fn(() => fresh()) const openProcessToken = vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => { @@ -86,9 +93,10 @@ function happyStubs(): HappyStubs { const createFileW = vi.fn(() => fresh()) const getNamedSecurityInfoW = vi.fn(( _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, ) => { koffi.encode(dacl, PVOID, 0n) + koffi.encode(sacl, PVOID, 0n) koffi.encode(descriptor, PVOID, 0n) return 0 }) @@ -97,6 +105,9 @@ function happyStubs(): HappyStubs { return 0 }) const setNamedSecurityInfoW = vi.fn(() => 0) + const localAlloc = vi.fn(() => fresh()) + const initializeAcl = vi.fn(() => 1) + const addMandatoryAce = vi.fn(() => 1) const getTokenInformation = vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => { if (info === null) { koffi.encode(needed, 'uint32', cls === abi.TokenGroups ? 24 : 8) @@ -149,25 +160,26 @@ function happyStubs(): HappyStubs { const resumeThread = vi.fn(() => 0) const terminateProcess = vi.fn(() => 1) const getStdHandle = vi.fn(() => fresh()) - const localFree = vi.fn(() => 0n) + const localFree = vi.fn(() => 0n as NativePtr) const closeHandle = vi.fn(() => 1) const getLastError = vi.fn(() => ERROR_BROKEN_PIPE) // the drains' clean EOF const formatMessageW = vi.fn(() => 0) - const api = { + const api = stubBindings({ openProcess, openProcessToken, convertStringSidToSidW, getTempPathW, createFileW, lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1), getNamedSecurityInfoW, setEntriesInAclW, setNamedSecurityInfoW, getTokenInformation, + localAlloc, initializeAcl, addMandatoryAce, getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken, setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW, peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW, setInformationJobObject, assignProcessToJobObject, resumeThread, terminateProcess, getStdHandle, localFree, closeHandle, getLastError, formatMessageW, - } as unknown as Win32Bindings + }) return { api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree, - createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, + createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, setTokenInformation, addMandatoryAce, } } @@ -338,10 +350,11 @@ describe('AclSandbox init', () => { localFree.mockImplementation(() => (inCleanup ? 1n : 0n)) getNamedSecurityInfoW.mockImplementation(( _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, ) => { if (inCleanup) return 2 // the cleanup's revocation read fails too koffi.encode(dacl, PVOID, 0n) + koffi.encode(sacl, PVOID, 0n) koffi.encode(descriptor, PVOID, 0n) return 0 }) @@ -352,7 +365,8 @@ describe('AclSandbox init', () => { tempWriteSid: 'S-1-4-9000-10-1', mode: 'workspace-write', }) - await expect(sandbox.init()).rejects.toThrow(/5 cleanup operation\(s\) also failed/u) + // Five SID frees/mutations fail plus the Low label SID the init allocated. + await expect(sandbox.init()).rejects.toThrow(/6 cleanup operation\(s\) also failed/u) expect(sandbox.tempDir).toBeUndefined() }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index b0d364202b..7632c301c0 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -78,9 +78,9 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", // The private-temp capability lets PowerShell complete its startup - // AppLocker probe, so without a host policy workspace-write stays in - // FullLanguage. Read-only cannot create those scratch files and fails - // that probe closed to ConstrainedLanguage (pinned below). + // AppLocker probe, so workspace-write stays in FullLanguage. Read-only + // cannot create those scratch files and fails that probe closed to + // ConstrainedLanguage (pinned below). '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, "try{Set-Content -Path (Join-Path $env:TEMP 'child-wrote.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};", @@ -395,19 +395,222 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 30_000) - it('partial boundary: an external Everyone-Modify directory stays writable under BOTH modes', () => { + it('delete-escape regression: deletes outside the granted roots are denied in BOTH modes, through every delete authority', () => { + // Windows authorizes a delete from the object's own DELETE right OR the + // parent directory's FILE_DELETE_CHILD, and the write-restricted + // intersection only covers the first — so `cmd /c del` used to delete + // outside the workspace. The Low label plus the grant's deny close both + // routes; these are the deleters a confined child reaches for. + for (const mode of ['read-only', 'workspace-write'] as const) { + const victims = { + cmd: join(scratchRoot, `delete-${mode}-cmd.txt`), + dotnet: join(scratchRoot, `delete-${mode}-dotnet.txt`), + remove: join(scratchRoot, `delete-${mode}-remove.txt`), + unlink: join(scratchRoot, `delete-${mode}-unlink.txt`), + inside: join(writableDir, `delete-${mode}-inside.txt`), + } + for (const path of Object.values(victims)) writeFileSync(path, 'delete me') + const quoted = (path: string) => `'${path}'` + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `& '${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\cmd.exe' /c del /f /q ${quoted(victims.cmd)} 2>&1 | Out-Null;` + + `'CMD-DEL: ' + $(if (Test-Path -LiteralPath ${quoted(victims.cmd)}) {'DENIED'} else {'DELETED'});`, + `try{[System.IO.File]::Delete(${quoted(victims.dotnet)});'DOTNET-DELETE: DELETED'}catch{'DOTNET-DELETE: DENIED'};`, + `try{Remove-Item -LiteralPath ${quoted(victims.remove)} -ErrorAction Stop;'REMOVE-ITEM: DELETED'}catch{'REMOVE-ITEM: DENIED'};`, + `& "${process.execPath}" -e "const fs=require('node:fs');try{fs.unlinkSync(process.argv[1]);console.log('NODE-UNLINK: DELETED')}catch(e){console.log('NODE-UNLINK: DENIED')}" ${quoted(victims.unlink)};`, + `try{[System.IO.File]::Delete(${quoted(victims.inside)});'INSIDE-DELETE: DELETED'}catch{'INSIDE-DELETE: DENIED'}`, + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).toBe(0) + expect(result.stdout, `mode: ${mode}`).toContain('CMD-DEL: DENIED') + expect(result.stdout, `mode: ${mode}`).toContain('DOTNET-DELETE: DENIED') + expect(result.stdout, `mode: ${mode}`).toContain('REMOVE-ITEM: DENIED') + expect(result.stdout, `mode: ${mode}`).toContain('NODE-UNLINK: DENIED') + for (const path of [victims.cmd, victims.dotnet, victims.remove, victims.unlink]) { + expect(existsSync(path), `mode: ${mode}, ${path}`).toBe(true) + } + // The granted root keeps its own delete authority: read-only carries no + // write SID, so only workspace-write may delete there. + expect(result.stdout, `mode: ${mode}`).toContain( + mode === 'workspace-write' ? 'INSIDE-DELETE: DELETED' : 'INSIDE-DELETE: DENIED', + ) + expect(existsSync(victims.inside), `mode: ${mode}`).toBe(mode === 'read-only') + } + }, 60_000) + + it('cross-root delete regression: one session cannot delete inside ANOTHER granted root', () => { + // Both roots carry the Low label, so the integrity check alone would not + // stop this: Windows also authorizes a delete from the parent directory's + // FILE_DELETE_CHILD right, which the capability intersection never reaches. + // The grant denies that right, leaving the capability DELETE bit as the + // only delete authority inside a granted root. + const otherWorkspace = join(scratchRoot, 'other-workspace') + const otherTemp = join(scratchRoot, 'other-temp') + mkdirSync(otherWorkspace) + mkdirSync(otherTemp) + const otherWorkspaceSid = workspaceWriteSid(otherWorkspace) + const otherTempSid = tempWriteSid(otherTemp) + const otherWorkspaceGrant = AclWriteGrant.create(otherWorkspaceSid) + const otherTempGrant = AclWriteGrant.create(otherTempSid) + otherWorkspaceGrant.add(otherWorkspace, true) + otherTempGrant.add(otherTemp) + // Session A's own roots, granted the same way, run through the runner. + const ownWorkspace = join(scratchRoot, 'own-workspace') + const ownTemp = join(scratchRoot, 'own-temp') + mkdirSync(ownWorkspace) + mkdirSync(ownTemp) + const ownWorkspaceSid = workspaceWriteSid(ownWorkspace) + const ownTempSid = tempWriteSid(ownTemp) + const ownWorkspaceGrant = AclWriteGrant.create(ownWorkspaceSid) + const ownTempGrant = AclWriteGrant.create(ownTempSid) + ownWorkspaceGrant.add(ownWorkspace, true) + ownTempGrant.add(ownTemp) + try { + const victims = { + ownWork: join(ownWorkspace, 'victim.txt'), + otherWork: join(otherWorkspace, 'victim.txt'), + ownTemp: join(ownTemp, 'victim.txt'), + otherTemp: join(otherTemp, 'victim.txt'), + } + for (const path of Object.values(victims)) writeFileSync(path, 'delete me') + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + ...Object.entries(victims).map(([name, path]) => + `try{[System.IO.File]::Delete('${path}');'${name}: DELETED'}catch{'${name}: DENIED'};`), + ].join('') + const result = runRunner([ + '--workspace', ownWorkspace, '--temp', ownTemp, '--mode', 'workspace-write', + '--write-sid', ownWorkspaceSid, '--temp-write-sid', ownTempSid, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('ownWork: DELETED') + expect(result.stdout).toContain('ownTemp: DELETED') + expect(result.stdout).toContain('otherWork: DENIED') + expect(result.stdout).toContain('otherTemp: DENIED') + expect(existsSync(victims.otherWork)).toBe(true) + expect(existsSync(victims.otherTemp)).toBe(true) + expect(existsSync(victims.ownWork)).toBe(false) + expect(existsSync(victims.ownTemp)).toBe(false) + } finally { + ownWorkspaceGrant.dispose() + ownTempGrant.dispose() + otherWorkspaceGrant.dispose() + otherTempGrant.dispose() + rmSync(ownTemp, { recursive: true, force: true }) + rmSync(otherTemp, { recursive: true, force: true }) + } + }, 60_000) + + it('NUL writes stay ambient under the Low token in BOTH modes', () => { + // The device DACL grants Everyone write and carries no higher label, so + // the documented `> NUL` redirection must survive the lowered token. The + // redirection stays inside cmd's own command line: PowerShell's `>nul` + // opens a file named nul instead, which is a different probe. + const cmd = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32\\cmd.exe') + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `& '${cmd}' /c 'echo ok>NUL&&echo CMD-NUL: OK';`, + `& "${process.execPath}" -e "try{require('node:fs').writeFileSync(process.argv[1],'x');console.log('NODE-NUL: OK')}catch(e){console.log('NODE-NUL: DENIED '+e.code)}" '\\\\.\\NUL'`, + ].join('') + for (const mode of ['read-only', 'workspace-write'] as const) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).toBe(0) + expect(result.stdout, `mode: ${mode}`).toContain('CMD-NUL: OK') + expect(result.stdout, `mode: ${mode}`).toContain('NODE-NUL: OK') + } + }, 30_000) + + it('a FullControl open inside a granted root still works for files (the deny inherits to containers only)', () => { + // The ambient-delete deny is 0x40, a member of FILE_ALL_ACCESS: inheriting + // it onto files would deny every GENERIC_ALL/FullControl open by the user, + // Administrators, SYSTEM, or the DSH host. Directories inside a granted + // root keep the deny (that is where FILE_DELETE_CHILD is evaluated), so a + // FullControl open of a DIRECTORY is the documented cost of the deny. + const granted = join(scratchRoot, 'fullcontrol-root') + const child = join(granted, 'child') + mkdirSync(granted) + mkdirSync(child) + writeFileSync(join(granted, 'file.txt'), 'x') + writeFileSync(join(child, 'deep.txt'), 'x') + const grant = AclWriteGrant.create(workspaceWriteSid(granted)) + grant.add(granted, true) + try { + const probe = ` +$ErrorActionPreference='SilentlyContinue' +Add-Type -Namespace P -Name F -MemberDefinition @' +[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode, EntryPoint="CreateFileW")] +public static extern IntPtr CreateFileW(string n, uint a, uint s, IntPtr sa, uint d, uint f, IntPtr t); +[DllImport("kernel32.dll", SetLastError=true)] +public static extern bool CloseHandle(IntPtr h); +'@ | Out-Null +function TryOpen([string]$label, [string]$path) { + $h = [P.F]::CreateFileW($path, 0x10000000, 7, [IntPtr]::Zero, 3, 0x02000000, [IntPtr]::Zero) + if ($h -eq [IntPtr]::new(-1)) { "$($label): DENIED" } else { [void][P.F]::CloseHandle($h); "$($label): OK" } +} +TryOpen 'FILE' '${join(granted, 'file.txt')}' +TryOpen 'NESTED-FILE' '${join(child, 'deep.txt')}' +TryOpen 'DIRECTORY' '${child}' +` + const result = spawnSync('pwsh', ['-NoLogo', '-NonInteractive', '-NoProfile', '-Command', probe], { encoding: 'utf8', timeout: 60_000 }) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('FILE: OK') + expect(result.stdout).toContain('NESTED-FILE: OK') + expect(result.stdout).toContain('DIRECTORY: DENIED') + } finally { + grant.dispose() + rmSync(granted, { recursive: true, force: true }) + } + }, 60_000) + + it('revoking one of two grants on a directory leaves the shared Low label usable', () => { + // Two capabilities may target one directory; the revoke of the first must + // not strip the label the second one's child still writes through. + const shared = join(scratchRoot, 'shared-root') + mkdirSync(shared) + const sidA = workspaceWriteSid(shared) + const sidB = `${sidA}-1` + const grantA = AclWriteGrant.create(sidA) + const grantB = AclWriteGrant.create(sidB) + grantA.add(shared, true) + grantB.add(shared) + try { + grantB.dispose() // revokes B's ACE, must keep the shared label + const target = join(shared, 'after-revoke.txt') + const result = runRunner([ + '--workspace', shared, '--temp', isolatedTemp, '--mode', 'workspace-write', + '--write-sid', sidA, '--temp-write-sid', tempWriteSid(isolatedTemp), + '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'written')", target, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(existsSync(target)).toBe(true) + } finally { + grantA.dispose() + rmSync(shared, { recursive: true, force: true }) + } + }, 60_000) + + it('the Low mandatory label closes the Everyone-Modify ambient boundary under BOTH modes', () => { // Everyone is a required keep-alive restricting SID: without it early DLL // initialization and CNG fail. A normal DACL that grants Everyone Modify - // therefore also clears the WRITE_RESTRICTED pass-2 check. Pin this - // unavoidable gap beside the provider's `partial` enforcement report. + // therefore also clears the WRITE_RESTRICTED pass-2 check. The Low label + // still denies the write, because the kernel evaluates the integrity + // policy inside the access check regardless of which right supplied the + // authority. for (const mode of ['read-only', 'workspace-write'] as const) { const target = join(worldWritableDir, `${mode}.txt`) const result = runRunner([ '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'written')", target, ]) - expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).toBe(0) - expect(existsSync(target), `mode: ${mode}`).toBe(true) + expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).not.toBe(0) + expect(existsSync(target), `mode: ${mode}`).toBe(false) } }, 30_000) diff --git a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts index 6149d75277..480f514952 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts @@ -15,12 +15,17 @@ import koffi from 'koffi' import { allocBytes, isNullPtr } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import { - createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant, + createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, restrictTokenIntegrity, setTokenDefaultDaclGrant, } from '../src/token.ts' import * as abi from '../src/win32-abi.ts' const PVOID = koffi.pointer('void') +/** Stub binding table: only the members a test drives, so the rest are never called. */ +function stubBindings(overrides: Partial): Win32Bindings { + return overrides as Win32Bindings +} + describe('openCurrentProcessToken failure paths', () => { it('reports when OpenProcess yields no handle', () => { const api = { @@ -371,6 +376,57 @@ describe('setTokenDefaultDaclGrant failure paths', () => { }) }) +describe('restrictTokenIntegrity', () => { + it('sets TokenIntegrityLevel to the Low label SID with SE_GROUP_INTEGRITY', () => { + const lowSid = allocBytes(12) + const setTokenInformation = vi.fn(( + _token: unknown, cls: number, info: Buffer, length: number, + ) => { + expect(cls).toBe(abi.TokenIntegrityLevel) + expect(length).toBe(abi.TOKEN_MANDATORY_LABEL_SIZE + 12) + expect(info.readBigUInt64LE(0)).toBe(koffi.address(lowSid)) + expect(info.readUInt32LE(8)).toBe(abi.SE_GROUP_INTEGRITY) + return 1 + }) + const api = stubBindings({ getLengthSid: vi.fn(() => 12), setTokenInformation }) + restrictTokenIntegrity(api, 5n as NativePtr, lowSid) + expect(setTokenInformation).toHaveBeenCalledTimes(1) + }) + + it('fails closed when the Low label SID has no length', () => { + const api = stubBindings({ + getLengthSid: vi.fn(() => 0), + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + }) + let caught: unknown + try { + restrictTokenIntegrity(api, 5n as NativePtr, allocBytes(12)) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetLengthSid') + }) + + it('fails closed when SetTokenInformation rejects the integrity level', () => { + const api = stubBindings({ + getLengthSid: vi.fn(() => 12), + setTokenInformation: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + }) + let caught: unknown + try { + restrictTokenIntegrity(api, 5n as NativePtr, allocBytes(12)) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetTokenInformation') + }) +}) + describe('createRestrictedToken failure paths', () => { it('builds the read-only restricting list without a write SID', () => { const create = vi.fn(( diff --git a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp index 55a3d9c9d8..b38017f1ac 100644 --- a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp +++ b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp @@ -50,12 +50,32 @@ int wmain() P((int)GRANT_ACCESS); P((int)REVOKE_ACCESS); P(SUB_CONTAINERS_AND_OBJECTS_INHERIT); + P(CONTAINER_INHERIT_ACE); P(MAX_PATH); P(ERROR_SUCCESS); + P((int)ACCESS_DENIED_ACE_TYPE); + P((int)DENY_ACCESS); + P(LABEL_SECURITY_INFORMATION); + P((int)SYSTEM_MANDATORY_LABEL_ACE_TYPE); + P(SYSTEM_MANDATORY_LABEL_NO_WRITE_UP); + P((int)TokenIntegrityLevel); + P(SE_GROUP_INTEGRITY); + P((int)WinLowLabelSid); + P(sizeof(TOKEN_MANDATORY_LABEL)); + P(offsetof(TOKEN_MANDATORY_LABEL, Label)); + P(sizeof(ACL)); + P(sizeof(SYSTEM_MANDATORY_LABEL_ACE)); + P(ACL_REVISION); + P(LPTR); static_assert(sizeof(EXPLICIT_ACCESS_W) == 48, "EXPLICIT_ACCESS_W size"); static_assert(sizeof(TRUSTEE_W) == 32, "TRUSTEE_W size"); static_assert(sizeof(SID_AND_ATTRIBUTES) == 16, "SID_AND_ATTRIBUTES size"); + static_assert(sizeof(TOKEN_MANDATORY_LABEL) == 16, "TOKEN_MANDATORY_LABEL size"); + static_assert(sizeof(ACL) == 8, "ACL header size"); + static_assert(sizeof(SYSTEM_MANDATORY_LABEL_ACE) == 12, "mandatory label ACE body size"); + static_assert(ACL_REVISION == 2, "ACL revision"); + static_assert(LPTR == 0x40, "LocalAlloc flags"); static_assert(SECURITY_MAX_SID_SIZE == 68, "SECURITY_MAX_SID_SIZE"); static_assert(TOKEN_QUERY == 0x8 && TOKEN_DUPLICATE == 0x2 && TOKEN_ADJUST_DEFAULT == 0x80 && TOKEN_ASSIGN_PRIMARY == 0x1, "token rights"); static_assert(SE_GROUP_LOGON_ID == 0xC0000000, "logon id attr"); diff --git a/packages/shell/pwsh-sandbox/README.i18n.yaml b/packages/shell/pwsh-sandbox/README.i18n.yaml index a322e49d44..289ac2384f 100644 --- a/packages/shell/pwsh-sandbox/README.i18n.yaml +++ b/packages/shell/pwsh-sandbox/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/shell/pwsh-sandbox/README.md -README.md: cddcce511d01f3c10208304ea55bf6156ccf2429 -README.zh.md: 40aa055770e526cc41607f0e4cd63273b80ebc15 +# pnpm run verify-translation-pairing --write packages//shell//pwsh-sandbox//README.md +README.md: 34845dd2cc5eee1dd36a76bf63e266d6b06c7276 +README.zh.md: edf99c6b0930a7d603523e86af4c453992fc578a diff --git a/packages/shell/pwsh-sandbox/README.md b/packages/shell/pwsh-sandbox/README.md index cddcce511d..34845dd2cc 100644 --- a/packages/shell/pwsh-sandbox/README.md +++ b/packages/shell/pwsh-sandbox/README.md @@ -35,7 +35,7 @@ Choose it when a deployment needs file-level confinement for PowerShell commands | Mode | File effects | |---|---| -| `read-only` (default) | Writes are denied; the boundary stays partial because the restricted token retains Everyone | +| `read-only` (default) | Writes are denied; the boundary stays partial for the shared hard-link, unconfined-read, and AppContainer-ACL limits | | `workspace-write` | Writes under the policy's workspace root plus a private temp directory; `TMP`/`TEMP` are rewritten to it before spawning | | `danger-full-access` | No confinement; the provider is never consulted, and results carry `sandbox: { mode, denied: false }` | @@ -140,7 +140,7 @@ These limits define when this executor is only a partial boundary on Windows. Th - **Reads are unrestricted on Windows** — the ACL runner restricts writes only; the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`. - **Windows workspace-write temp authority is private** — per live session/workspace pair; agentless calls receive a fresh private directory per invocation; the ambient temp root is never granted, and the runner rewrites `TMP`/`TEMP` to the private directory before spawning. -- **Windows read-only grants no explicit writable root but remains partial** — the restricted token must retain Everyone; objects whose DACL grants Everyone write access — including compatible opens of the NUL device — remain ambient authority, while PowerShell's `> $null` redirection still works without opening NUL. +- **Windows read-only grants no explicit writable root but remains partial** — NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. NUL stays writable in both modes because the device DACL grants Everyone write and carries no higher label; PowerShell's `> $null` redirection still works without opening it. ### Dev Note diff --git a/packages/shell/pwsh-sandbox/README.zh.md b/packages/shell/pwsh-sandbox/README.zh.md index 40aa055770..edf99c6b09 100644 --- a/packages/shell/pwsh-sandbox/README.zh.md +++ b/packages/shell/pwsh-sandbox/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" | 模式 | 文件影响 | |---|---| -| `read-only`(默认) | 写入被拒绝;由于受限令牌必须保留 Everyone,边界仍是不完整的 | +| `read-only`(默认) | 写入被拒绝;边界仍受共享的硬链接、读取不受限与 AppContainer ACL 限制 | | `workspace-write` | 只能写入策略的工作区根目录加一个私有临时目录;spawn 前 `TMP`/`TEMP` 会被重写到该目录 | | `danger-full-access` | 不作限制;绝不咨询提供方,结果携带 `sandbox: { mode, denied: false }` | @@ -140,7 +140,7 @@ kind: "package-reference" - **Windows 上读不受限**——ACL runner 只限写;读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。 - **Windows workspace-write 的临时权限按每个活跃的会话/工作区对私有**——无 agent(智能体)的调用每次都获得一个新的私有目录;环境临时根目录绝不会被授权,runner 会在 spawn 前将 `TMP`/`TEMP` 重写为该私有目录。 -- **Windows read-only 不授予任何显式可写根目录,但仍为部分强制执行**——受限令牌必须保留 Everyone;DACL 向 Everyone 授予写访问的对象——包括以兼容方式打开的 NUL 设备——仍构成环境权限来源,而 PowerShell 的 `> $null` 重定向仍可工作,且不会打开 NUL。 +- **Windows read-only 不授予任何显式可写根目录,但仍为部分强制执行**——NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。NUL 在两种模式下仍可写,因为设备 DACL 向 Everyone 授予写权限且不带更高的标签;PowerShell 的 `> $null` 重定向仍然工作,且不会打开 NUL。 ### 开发备注 diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 2d1e43547f..e6c1d70634 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -1627,14 +1627,9 @@ "9c749359b9458267505dc9d87b2b4e2a73e874c0e3908d7fa06e4fd4fd11cad4": 1 }, "packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts": { - "0ee37bb1bf169e1de1030cd3449529c550283dccba4ea2d9d2af967b9ac51895": 1, "24defbb8f6adc4ec8ebd3443b233aceadae85ab486413320de0967eaf7d43032": 1, - "d7b28f469106e3e88d52c1742fe001ac7807bc1bc21c0fd4ddcb5bd9b97bf31b": 1, "ff7b125eae197db9cb8866293613a5de2a984d42b4487b2cc37eeacc8c300783": 1 }, - "packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts": { - "916c26b0b6721f519ca6c95b93446c1b16b174f9bfb067e79f9cb03cec8fc87c": 1 - }, "packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts": { "d187b3ab8af0b780660e2b764b553ccff82e3af3254598ee18a059e5bd0381f5": 1 }, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index f92eaf7add..69f4474289 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -826,6 +826,7 @@ function docSyncLeafGates(options: { pnpmExec('docs-site-projection', [ 'vitest', 'run', 'scripts/project-doc-site.spec.ts', 'scripts/verify-doc-site-fragments.spec.ts', 'website/tests/mermaid-viewer.spec.ts', + 'website/tests/image-viewer.spec.ts', 'website/tests/code-groups.spec.ts', 'website/tests/page-markdown-actions.spec.ts', 'website/tests/raw-markdown.spec.ts', ], { diff --git a/website/.vitepress/theme/image-viewer.ts b/website/.vitepress/theme/image-viewer.ts new file mode 100644 index 0000000000..18070d6245 --- /dev/null +++ b/website/.vitepress/theme/image-viewer.ts @@ -0,0 +1,127 @@ +/** Full-viewport viewing of loaded, standalone content images in documentation pages. */ +import { labelButton, type MediaViewer, viewerButton } from './media-viewer.ts' + +const messages = { + en: 'View image fullscreen: {alt}', + zh: '全屏查看图片:{alt}', +} + +interface ImageEntry { + container: HTMLElement + button: HTMLButtonElement + events: AbortController + source: string +} + +/** Enhances standalone image paragraphs without moving or replacing their source images. */ +export class ImageViewer { + readonly #entries = new Map() + readonly #observer: MutationObserver + readonly #events = new AbortController() + #active: HTMLImageElement | undefined + #close: (() => void) | undefined + + /** + * @param doc Browser document containing VitePress content. + * @param language Current VitePress language, read again when entries refresh. + * @param viewer Shared modal owner for images and diagrams. + */ + constructor( + private readonly doc: Document, + private readonly language: () => string, + private readonly viewer: Pick, + ) { + const root = doc.querySelector('#VPContent') ?? doc.body + const scan = (): void => { this.#scan() } + this.#observer = new MutationObserver(scan) + this.#observer.observe(root, { + childList: true, subtree: true, attributes: true, + attributeFilter: ['src', 'srcset', 'alt', 'role', 'aria-hidden', 'data-no-zoom'], + }) + const options = { capture: true, signal: this.#events.signal } + root.addEventListener('load', scan, options) + root.addEventListener('error', scan, options) + this.#scan() + } + + /** Close the image view and refresh controls after route, language, or theme changes. */ + refresh(): void { + this.#closeActive() + this.#scan() + } + + /** Remove image enhancements, event handlers, observers, and any active image modal. */ + dispose(): void { + this.#observer.disconnect() + this.#events.abort() + this.#closeActive() + for (const [image, entry] of this.#entries) this.#remove(image, entry) + } + + #closeActive(): void { + this.#close?.() + this.#close = undefined + this.#active = undefined + } + + #remove(image: HTMLImageElement, entry: ImageEntry): void { + if (image === this.#active) this.#closeActive() + entry.events.abort() + entry.button.remove() + entry.container.classList.remove('dsh-image-container') + image.classList.remove('dsh-image-zoomable') + this.#entries.delete(image) + } + + #scan(): void { + const images = new Set(Array.from(this.doc.querySelectorAll('.vp-doc p > img')).filter((image) => { + const entry = this.#entries.get(image) + return image.complete && image.naturalWidth > 0 && image.naturalHeight > 0 && image.alt.trim() + && !image.closest('a, button, .mermaid, [data-no-zoom], [aria-hidden="true"], [role="presentation"], [role="none"]') + && Array.from(image.parentNode?.childNodes ?? []).every(node => node === image || node === entry?.button + || (node.nodeType === 3 && !node.textContent?.trim())) + })) + for (const [image, entry] of this.#entries) { + if (!images.has(image) || image.parentElement !== entry.container || !entry.button.isConnected + || entry.source !== (image.currentSrc || image.src)) { + this.#remove(image, entry) + } + } + const copy = this.language().startsWith('zh') ? messages.zh : messages.en + for (const image of images) { + const label = copy.replace('{alt}', () => image.alt) + const existing = this.#entries.get(image) + if (existing) { + if (existing.button.getAttribute('aria-label') !== label) labelButton(existing.button, label) + continue + } + const container = image.parentElement + if (!container) continue + const button = viewerButton(this.doc, label, 'open') + button.className = 'dsh-image-open' + button.setAttribute('aria-haspopup', 'dialog') + const events = new AbortController() + const open = (): void => { + this.#closeActive() + const content = this.doc.createElement('img') + content.src = image.currentSrc || image.src + content.alt = image.alt + content.draggable = false + this.#close = this.viewer.open({ + element: content, width: image.naturalWidth, height: image.naturalHeight, + title: image.alt, originalSize: true, + }, button, () => { + this.#close = undefined + this.#active = undefined + }) + this.#active = image + } + button.addEventListener('click', open, { signal: events.signal }) + image.addEventListener('click', open, { signal: events.signal }) + container.classList.add('dsh-image-container') + image.classList.add('dsh-image-zoomable') + container.append(button) + this.#entries.set(image, { container, button, events, source: image.currentSrc || image.src }) + } + } +} diff --git a/website/.vitepress/theme/index.ts b/website/.vitepress/theme/index.ts index 6a43c904e8..b1e2508eac 100644 --- a/website/.vitepress/theme/index.ts +++ b/website/.vitepress/theme/index.ts @@ -1,10 +1,12 @@ -/** Default documentation theme with Markdown actions and a client-only Mermaid viewer. */ +/** Default documentation theme with Markdown actions and a client-only image and diagram viewer. */ import DefaultTheme from 'vitepress/theme' import { useData, useRoute, type Theme } from 'vitepress' import { defineComponent, h, onBeforeUnmount, onMounted, watch } from 'vue' import type { MermaidViewer } from './mermaid-viewer.ts' +import type { ImageViewer } from './image-viewer.ts' +import type { MediaViewer } from './media-viewer.ts' import { PageMarkdownActions } from './page-markdown-actions.ts' -import './mermaid-viewer.css' +import './media-viewer.css' import './code-group.css' import './page-markdown-actions.css' @@ -16,15 +18,27 @@ export default { const { lang, isDark, frontmatter, site, page } = useData() const route = useRoute() let viewer: MermaidViewer | undefined + let images: ImageViewer | undefined + let media: MediaViewer | undefined let disposed = false onMounted(async () => { - const { installMermaidViewer } = await import('./mermaid-viewer.ts') - if (!disposed) viewer = installMermaidViewer(document, () => lang.value) + const [{ installMermaidViewer }, { ImageViewer }, { MediaViewer }] = await Promise.all([ + import('./mermaid-viewer.ts'), import('./image-viewer.ts'), import('./media-viewer.ts'), + ]) + if (disposed) return + media = new MediaViewer(document, () => lang.value) + viewer = installMermaidViewer(document, () => lang.value, media) + images = new ImageViewer(document, () => lang.value, media) }) - watch([() => route.path, lang, isDark], () => viewer?.refresh(), { flush: 'post' }) + watch([() => route.path, lang, isDark], () => { + viewer?.refresh() + images?.refresh() + }, { flush: 'post' }) onBeforeUnmount(() => { disposed = true viewer?.dispose() + images?.dispose() + media?.close() }) return () => h(DefaultTheme.Layout, null, { 'doc-before': () => { diff --git a/website/.vitepress/theme/mermaid-viewer.css b/website/.vitepress/theme/media-viewer.css similarity index 67% rename from website/.vitepress/theme/mermaid-viewer.css rename to website/.vitepress/theme/media-viewer.css index e98be69ca9..0d9d5959f2 100644 --- a/website/.vitepress/theme/mermaid-viewer.css +++ b/website/.vitepress/theme/media-viewer.css @@ -4,7 +4,8 @@ } .vp-doc .dsh-diagram-open, -.dsh-diagram-viewer button { +.vp-doc .dsh-image-open, +.dsh-media-viewer button { display: inline-flex; align-items: center; justify-content: center; @@ -25,7 +26,7 @@ right: 0; } -.dsh-diagram-icon { +.dsh-media-icon { width: 20px; height: 20px; fill: none; @@ -36,19 +37,21 @@ } .dsh-diagram-open:hover, -.dsh-diagram-viewer button:hover, -.dsh-diagram-viewer button[aria-expanded="true"] { +.dsh-image-open:hover, +.dsh-media-viewer button:hover, +.dsh-media-viewer button[aria-expanded="true"] { color: var(--vp-c-brand-1); background: var(--vp-c-bg-soft); } .dsh-diagram-open:focus-visible, -.dsh-diagram-viewer button:focus-visible { +.dsh-image-open:focus-visible, +.dsh-media-viewer button:focus-visible { outline: 2px solid var(--vp-c-brand-1); outline-offset: 2px; } -.dsh-diagram-viewer { +.dsh-media-viewer { position: fixed; inset: 0; width: 100%; @@ -64,7 +67,7 @@ box-sizing: border-box; } -.dsh-diagram-title { +.dsh-media-title { position: absolute; top: 24px; left: 24px; @@ -76,9 +79,9 @@ font-size: 13px; } -.dsh-diagram-close { position: absolute; top: 12px; right: 16px; } +.dsh-media-close { position: absolute; top: 12px; right: 16px; } -.dsh-diagram-toolbar { +.dsh-media-toolbar { position: absolute; bottom: 20px; left: 50%; @@ -93,7 +96,7 @@ box-shadow: 0 3px 12px #0000000a; } -.dsh-diagram-scale { +.dsh-media-scale { min-width: 48px; text-align: center; color: var(--vp-c-text-2); @@ -101,16 +104,16 @@ font-variant-numeric: tabular-nums; } -.dsh-diagram-toolbar .dsh-diagram-fit { +.dsh-media-toolbar .dsh-media-fit { margin-left: 6px; border-left: 1px solid var(--vp-c-divider); border-top-left-radius: 0; border-bottom-left-radius: 0; } -.dsh-diagram-help-toggle { position: absolute; bottom: 25px; right: 16px; } +.dsh-media-help-toggle { position: absolute; bottom: 25px; right: 16px; } -.dsh-diagram-help { +.dsh-media-help { position: absolute; right: 16px; bottom: 80px; @@ -124,19 +127,23 @@ font-size: 13px; } -.dsh-diagram-viewport { +.dsh-media-viewport { position: absolute; inset: 64px 16px 88px; overflow: hidden; overscroll-behavior: contain; } -.dsh-diagram-paper { position: absolute; inset: 0; } +.dsh-media-paper { position: absolute; inset: 0; } @media (max-width: 600px) { - .dsh-diagram-title { top: 20px; left: 16px; } - .dsh-diagram-close { top: 8px; right: 8px; } - .dsh-diagram-viewport { inset: 56px 8px 80px; } - .dsh-diagram-toolbar { bottom: 12px; } - .dsh-diagram-help-toggle { right: 8px; bottom: 17px; } + .dsh-media-title { top: 20px; left: 16px; } + .dsh-media-close { top: 8px; right: 8px; } + .dsh-media-viewport { inset: 56px 8px 80px; } + .dsh-media-toolbar { bottom: 12px; } + .dsh-media-help-toggle { right: 8px; bottom: 17px; } } + +.vp-doc .dsh-image-container { position: relative; width: fit-content; max-width: 100%; } +.vp-doc .dsh-image-zoomable { cursor: zoom-in; } +.vp-doc .dsh-image-open { position: absolute; bottom: 8px; right: 8px; } diff --git a/website/.vitepress/theme/media-viewer.ts b/website/.vitepress/theme/media-viewer.ts new file mode 100644 index 0000000000..c26981aaa2 --- /dev/null +++ b/website/.vitepress/theme/media-viewer.ts @@ -0,0 +1,232 @@ +/** Shared modal, focus, and pan/zoom controls for documentation images and diagrams. */ +import Panzoom from '@panzoom/panzoom' + +const messages = { + en: { + zoomIn: 'Zoom in', zoomOut: 'Zoom out', fit: 'Fit view', original: 'Original size (100%), currently {scale}', + close: 'Close', helpLabel: 'Viewer help', + help: 'Scroll or pinch to zoom · Drag or use arrow keys to pan · Esc to close', + }, + zh: { + zoomIn: '放大', zoomOut: '缩小', fit: '适应窗口', original: '原始尺寸(100%),当前{scale}', + close: '关闭', helpLabel: '查看器帮助', + help: '滚轮或双指缩放 · 拖动或方向键平移 · Esc 关闭', + }, +} satisfies Record> + +const icons = { + open: 'M14 4h6v6M20 4l-7 7M10 20H4v-6M4 20l7-7', + zoomIn: 'M5 12h14M12 5v14', zoomOut: 'M5 12h14', + fit: 'M9 4H4v5M15 4h5v5M4 15v5h5M20 15v5h-5', + close: 'M6 6l12 12M18 6 6 18', + help: 'M9.1 8a3 3 0 0 1 5.8 1c0 2-3 2-3 4M12 17v.1', +} + +/** + * Set the accessible name and hover hint of a viewer control. + * @param element Button to label. + * @param label Localized control name. + */ +export function labelButton(element: HTMLButtonElement, label: string): void { + element.setAttribute('aria-label', label) + element.title = label +} + +/** + * Create a viewer control with a decorative SVG icon. + * @param doc Document that owns the control. + * @param label Localized control name. + * @param icon Viewer icon to display. + * @returns A native button ready for its click handler. + */ +export function viewerButton(doc: Document, label: string, icon: keyof typeof icons): HTMLButtonElement { + const element = doc.createElement('button') + element.type = 'button' + labelButton(element, label) + const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg') + svg.classList.add('dsh-media-icon') + svg.setAttribute('viewBox', '0 0 24 24') + svg.setAttribute('aria-hidden', 'true') + svg.setAttribute('focusable', 'false') + const path = doc.createElementNS(svg.namespaceURI, 'path') + path.setAttribute('d', icons[icon]) + svg.append(path) + element.append(svg) + return element +} + +/** Detached content with intrinsic dimensions in CSS pixels; the viewer owns its lifetime. */ +interface MediaContent { + element: HTMLElement | SVGSVGElement + width: number + height: number + title: string + originalSize?: boolean +} + +/** Owns at most one modal and restores page scrolling and trigger focus when it closes. */ +export class MediaViewer { + #close: (() => void) | undefined + + /** + * @param doc Browser document containing the viewer's triggers. + * @param language Current VitePress language, read when opening the modal. + */ + constructor(private readonly doc: Document, private readonly language: () => string) {} + + /** Close the active modal and release its observers, listeners, and scroll lock. */ + close(): void { + this.#close?.() + } + + /** + * Open content at a scale that fits the viewport, closing any previous modal. + * @param content Detached image or SVG and its display metadata. + * @param trigger Connected control to focus after the modal closes. + * @param onClose Notify the adapter once after releasing the modal, including setup failure. + * @returns Idempotent closer for this modal only. + */ + open(content: MediaContent, trigger: HTMLElement, onClose: () => void): () => void { + this.close() + const doc = this.doc + const copy = this.language().startsWith('zh') ? messages.zh : messages.en + const dialog = doc.createElement('dialog') + dialog.className = 'dsh-media-viewer' + dialog.setAttribute('aria-labelledby', 'dsh-media-title') + const toolbar = doc.createElement('div') + toolbar.className = 'dsh-media-toolbar' + const title = doc.createElement('span') + title.className = 'dsh-media-title' + title.id = 'dsh-media-title' + title.textContent = content.title + const zoomOut = viewerButton(doc, copy.zoomOut, 'zoomOut') + const zoomIn = viewerButton(doc, copy.zoomIn, 'zoomIn') + const original = content.originalSize ? doc.createElement('button') : undefined + if (original) original.type = 'button' + const scaleLabel = original ?? doc.createElement('span') + scaleLabel.className = 'dsh-media-scale' + const fit = viewerButton(doc, copy.fit, 'fit') + fit.className = 'dsh-media-fit' + const close = viewerButton(doc, copy.close, 'close') + close.className = 'dsh-media-close' + close.autofocus = true + toolbar.append(zoomOut, scaleLabel, zoomIn, fit) + const helpToggle = viewerButton(doc, copy.helpLabel, 'help') + helpToggle.className = 'dsh-media-help-toggle' + helpToggle.setAttribute('aria-expanded', 'false') + helpToggle.setAttribute('aria-controls', 'dsh-media-help-text') + const help = doc.createElement('p') + help.className = 'dsh-media-help' + help.id = 'dsh-media-help-text' + help.hidden = true + help.textContent = copy.help + const viewport = doc.createElement('div') + viewport.className = 'dsh-media-viewport' + const paper = doc.createElement('div') + paper.className = 'dsh-media-paper' + // Shared content lives in a shadow root so Mermaid copies cannot resolve their + // embedded styles and fragment IDs against the original diagram. + const shadow = paper.attachShadow({ mode: 'open' }) + const clone = content.element + Object.assign(clone.style, { + position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', + width: `${content.width}px`, height: `${content.height}px`, maxWidth: 'none', display: 'block', + }) + shadow.append(clone) + viewport.append(paper) + dialog.append(viewport, title, close, toolbar, helpToggle, help) + doc.body.append(dialog) + + const overflow = doc.body.style.overflow + const listeners = new AbortController() + let panzoom: ReturnType | undefined + let resize: ResizeObserver | undefined + let closed = false + const cleanup = (): void => { + if (closed) return + closed = true + listeners.abort() + resize?.disconnect() + panzoom?.destroy() + dialog.close() + dialog.remove() + doc.body.style.overflow = overflow + if (trigger.isConnected) trigger.focus({ preventScroll: true }) + this.#close = undefined + onClose() + } + this.#close = cleanup + try { + dialog.showModal() + doc.body.style.overflow = 'hidden' + const fitScale = (): number => Math.min( + 1, Math.max(1, viewport.clientWidth - 32) / content.width, + Math.max(1, viewport.clientHeight - 32) / content.height, + ) + const controller = Panzoom(paper, { + canvas: true, startScale: fitScale(), minScale: fitScale() / 2, + maxScale: 8, animate: false, pinchAndPan: true, + }) + panzoom = controller + const refit = (): void => { + const scale = fitScale() + controller.setOptions({ minScale: scale / 2 }) + controller.zoom(scale, { animate: false }) + controller.pan(0, 0, { animate: false }) + } + const options = { signal: listeners.signal } + const updateScale = (): void => { + const scale = `${Math.round(controller.getScale() * 100)}%` + scaleLabel.textContent = scale + if (original) labelButton(original, copy.original.replace('{scale}', scale)) + } + updateScale() + paper.addEventListener('panzoomchange', updateScale, options) + helpToggle.addEventListener('click', () => { + help.hidden = !help.hidden + helpToggle.setAttribute('aria-expanded', String(!help.hidden)) + }, options) + close.addEventListener('click', cleanup, options) + dialog.addEventListener('close', cleanup, options) + dialog.addEventListener('cancel', (event) => { + event.preventDefault() + cleanup() + }, options) + zoomIn.addEventListener('click', () => controller.zoomIn({ animate: false }), options) + zoomOut.addEventListener('click', () => controller.zoomOut({ animate: false }), options) + fit.addEventListener('click', refit, options) + original?.addEventListener('click', () => { + controller.zoom(1, { animate: false }) + controller.pan(0, 0, { animate: false }) + }, options) + viewport.addEventListener('wheel', event => controller.zoomWithWheel(event), { ...options, passive: false }) + dialog.addEventListener('keydown', (event) => { + if (event.key === 'Tab') { + const controls = [close, zoomOut, ...(original ? [original] : []), zoomIn, fit, helpToggle] + const current = controls.indexOf(doc.activeElement as HTMLButtonElement) + const next = current < 0 ? (event.shiftKey ? controls.length - 1 : 0) + : (current + (event.shiftKey ? -1 : 1) + controls.length) % controls.length + event.preventDefault() + controls[next]?.focus() + return + } + const distance = 64 / controller.getScale() + const offsets: Record = { + ArrowLeft: [distance, 0], ArrowRight: [-distance, 0], + ArrowUp: [0, distance], ArrowDown: [0, -distance], + } + const offset = offsets[event.key] + if (offset && !event.altKey && !event.ctrlKey && !event.metaKey) { + event.preventDefault() + controller.pan(...offset, { relative: true, animate: false }) + } + }, options) + resize = new ResizeObserver(refit) + resize.observe(viewport) + return cleanup + } catch (error) { + cleanup() + throw error + } + } +} diff --git a/website/.vitepress/theme/mermaid-viewer.ts b/website/.vitepress/theme/mermaid-viewer.ts index 4a7b63ad98..3872a8b7e2 100644 --- a/website/.vitepress/theme/mermaid-viewer.ts +++ b/website/.vitepress/theme/mermaid-viewer.ts @@ -1,25 +1,9 @@ /** Full-viewport viewing of asynchronously rendered documentation diagrams. */ -import Panzoom from '@panzoom/panzoom' +import { labelButton, type MediaViewer, viewerButton } from './media-viewer.ts' const messages = { - en: { - open: 'View diagram fullscreen', title: 'Diagram viewer', - zoomIn: 'Zoom in', zoomOut: 'Zoom out', fit: 'Fit view', close: 'Close', helpLabel: 'Viewer help', - help: 'Scroll or pinch to zoom · Drag or use arrow keys to pan · Esc to close', - }, - zh: { - open: '全屏查看图表', title: '图表查看器', - zoomIn: '放大', zoomOut: '缩小', fit: '适应窗口', close: '关闭', helpLabel: '查看器帮助', - help: '滚轮或双指缩放 · 拖动或方向键平移 · Esc 关闭', - }, -} satisfies Record> - -const icons = { - open: 'M14 4h6v6M20 4l-7 7M10 20H4v-6M4 20l7-7', - zoomIn: 'M5 12h14M12 5v14', zoomOut: 'M5 12h14', - fit: 'M9 4H4v5M15 4h5v5M4 15v5h5M20 15v5h-5', - close: 'M6 6l12 12M18 6 6 18', - help: 'M9.1 8a3 3 0 0 1 5.8 1c0 2-3 2-3 4M12 17v.1', + en: { open: 'View diagram fullscreen', title: 'Diagram viewer' }, + zh: { open: '全屏查看图表', title: '图表查看器' }, } /** Theme-owned resources; source SVG replacement automatically closes the current view. */ @@ -30,27 +14,6 @@ export interface MermaidViewer { dispose(): void } -function labelButton(element: HTMLButtonElement, label: string): void { - element.setAttribute('aria-label', label) - element.title = label -} - -function button(doc: Document, label: string, icon: keyof typeof icons): HTMLButtonElement { - const element = doc.createElement('button') - element.type = 'button' - labelButton(element, label) - const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg') - svg.classList.add('dsh-diagram-icon') - svg.setAttribute('viewBox', '0 0 24 24') - svg.setAttribute('aria-hidden', 'true') - svg.setAttribute('focusable', 'false') - const path = doc.createElementNS(svg.namespaceURI, 'path') - path.setAttribute('d', icons[icon]) - svg.append(path) - element.append(svg) - return element -} - function dimensions(svg: SVGSVGElement): { width: number; height: number } | undefined { const { width, height } = svg.viewBox.baseVal if (Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0) { @@ -59,147 +22,16 @@ function dimensions(svg: SVGSVGElement): { width: number; height: number } | und return undefined } -function openDiagram( - svg: SVGSVGElement, trigger: HTMLButtonElement, copy: typeof messages.en, onClose: () => void, -): () => void { - const size = dimensions(svg) - if (!size) return () => {} - const doc = svg.ownerDocument - const dialog = doc.createElement('dialog') - dialog.className = 'dsh-diagram-viewer' - dialog.setAttribute('aria-labelledby', 'dsh-diagram-title') - const toolbar = doc.createElement('div') - toolbar.className = 'dsh-diagram-toolbar' - const title = doc.createElement('span') - title.className = 'dsh-diagram-title' - title.id = 'dsh-diagram-title' - title.textContent = doc.querySelector('.vp-doc h1')?.textContent.trim() || copy.title - const zoomOut = button(doc, copy.zoomOut, 'zoomOut') - const zoomIn = button(doc, copy.zoomIn, 'zoomIn') - const scaleLabel = doc.createElement('span') - scaleLabel.className = 'dsh-diagram-scale' - const fit = button(doc, copy.fit, 'fit') - fit.className = 'dsh-diagram-fit' - const close = button(doc, copy.close, 'close') - close.className = 'dsh-diagram-close' - close.autofocus = true - toolbar.append(zoomOut, scaleLabel, zoomIn, fit) - const helpToggle = button(doc, copy.helpLabel, 'help') - helpToggle.className = 'dsh-diagram-help-toggle' - helpToggle.setAttribute('aria-expanded', 'false') - helpToggle.setAttribute('aria-controls', 'dsh-diagram-help-text') - const help = doc.createElement('p') - help.className = 'dsh-diagram-help' - help.id = 'dsh-diagram-help-text' - help.hidden = true - help.textContent = copy.help - const viewport = doc.createElement('div') - viewport.className = 'dsh-diagram-viewport' - const paper = doc.createElement('div') - paper.className = 'dsh-diagram-paper' - // Each Mermaid SVG embeds ID-scoped styles and fragment references. A shadow root - // keeps the enlarged copy's IDs and styles separate from the original diagram. - const shadow = paper.attachShadow({ mode: 'open' }) - const clone = svg.cloneNode(true) as SVGSVGElement - Object.assign(clone.style, { - position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', - width: `${size.width}px`, height: `${size.height}px`, maxWidth: 'none', display: 'block', - }) - shadow.append(clone) - viewport.append(paper) - dialog.append(viewport, title, close, toolbar, helpToggle, help) - doc.body.append(dialog) - - const overflow = doc.body.style.overflow - const listeners = new AbortController() - let panzoom: ReturnType | undefined - let resize: ResizeObserver | undefined - let closed = false - function cleanup(): void { - if (closed) return - closed = true - listeners.abort() - resize?.disconnect() - panzoom?.destroy() - dialog.close() - dialog.remove() - doc.body.style.overflow = overflow - if (trigger.isConnected) trigger.focus({ preventScroll: true }) - onClose() - } - try { - dialog.showModal() - doc.body.style.overflow = 'hidden' - const fitScale = (): number => Math.min( - 1, Math.max(1, viewport.clientWidth - 32) / size.width, - Math.max(1, viewport.clientHeight - 32) / size.height, - ) - const controller = Panzoom(paper, { - canvas: true, startScale: fitScale(), minScale: fitScale() / 2, - maxScale: 8, animate: false, pinchAndPan: true, - }) - panzoom = controller - const refit = (): void => { - const scale = fitScale() - controller.setOptions({ minScale: scale / 2 }) - controller.zoom(scale, { animate: false }) - controller.pan(0, 0, { animate: false }) - } - const options = { signal: listeners.signal } - const updateScale = (): void => { scaleLabel.textContent = `${Math.round(controller.getScale() * 100)}%` } - updateScale() - paper.addEventListener('panzoomchange', updateScale, options) - helpToggle.addEventListener('click', () => { - help.hidden = !help.hidden - helpToggle.setAttribute('aria-expanded', String(!help.hidden)) - }, options) - close.addEventListener('click', cleanup, options) - dialog.addEventListener('close', cleanup, options) - dialog.addEventListener('cancel', (event) => { - event.preventDefault() - cleanup() - }, options) - zoomIn.addEventListener('click', () => controller.zoomIn({ animate: false }), options) - zoomOut.addEventListener('click', () => controller.zoomOut({ animate: false }), options) - fit.addEventListener('click', refit, options) - viewport.addEventListener('wheel', event => controller.zoomWithWheel(event), { ...options, passive: false }) - dialog.addEventListener('keydown', (event) => { - if (event.key === 'Tab') { - const controls = [close, zoomOut, zoomIn, fit, helpToggle] - const current = controls.indexOf(doc.activeElement as HTMLButtonElement) - const next = current < 0 ? (event.shiftKey ? controls.length - 1 : 0) - : (current + (event.shiftKey ? -1 : 1) + controls.length) % controls.length - event.preventDefault() - controls[next]?.focus() - return - } - const distance = 64 / controller.getScale() - const offsets: Record = { - ArrowLeft: [distance, 0], ArrowRight: [-distance, 0], - ArrowUp: [0, distance], ArrowDown: [0, -distance], - } - const offset = offsets[event.key] - if (offset && !event.altKey && !event.ctrlKey && !event.metaKey) { - event.preventDefault() - controller.pan(...offset, { relative: true, animate: false }) - } - }, options) - resize = new ResizeObserver(refit) - resize.observe(viewport) - return cleanup - } catch (error) { - cleanup() - throw error - } -} - /** * Enhance rendered Mermaid SVGs without modifying the canonical Markdown or renderer. * @param doc Browser document containing VitePress content. * @param language Current VitePress language, read again when entries refresh. + * @param viewer Shared modal owner for images and diagrams. * @returns Resources owned by the mounted theme. */ -export function installMermaidViewer(doc: Document, language: () => string): MermaidViewer { +export function installMermaidViewer( + doc: Document, language: () => string, viewer: Pick, +): MermaidViewer { const entries = new Map() let active: SVGSVGElement | undefined let close: (() => void) | undefined @@ -212,7 +44,7 @@ export function installMermaidViewer(doc: Document, language: () => string): Mer const copy = language().startsWith('zh') ? messages.zh : messages.en const containers = new Set(doc.querySelectorAll('.vp-doc .mermaid')) for (const [container, entry] of entries) { - if (!containers.has(container) || container.querySelector('svg:not(.dsh-diagram-icon)') !== entry.svg || !entry.button.isConnected) { + if (!containers.has(container) || container.querySelector('svg:not(.dsh-media-icon)') !== entry.svg || !entry.button.isConnected) { if (entry.svg === active) closeActive() entry.button.remove() entries.delete(container) @@ -224,14 +56,21 @@ export function installMermaidViewer(doc: Document, language: () => string): Mer if (existing.button.getAttribute('aria-label') !== copy.open) labelButton(existing.button, copy.open) continue } - const svg = container.querySelector('svg:not(.dsh-diagram-icon)') + const svg = container.querySelector('svg:not(.dsh-media-icon)') if (!svg || !dimensions(svg)) continue - const trigger = button(doc, copy.open, 'open') + const trigger = viewerButton(doc, copy.open, 'open') trigger.className = 'dsh-diagram-open' trigger.setAttribute('aria-haspopup', 'dialog') trigger.addEventListener('click', () => { closeActive() - close = openDiagram(svg, trigger, language().startsWith('zh') ? messages.zh : messages.en, () => { + const size = dimensions(svg) + if (!size) return + const copy = language().startsWith('zh') ? messages.zh : messages.en + close = viewer.open({ + element: svg.cloneNode(true) as SVGSVGElement, + ...size, + title: doc.querySelector('.vp-doc h1')?.textContent.trim() || copy.title, + }, trigger, () => { close = undefined active = undefined }) diff --git a/website/AGENTS.md b/website/AGENTS.md index da410e9ea9..ba1853f8f0 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -18,6 +18,6 @@ Run `pnpm docs:check` after changing this subtree; the gate rejects additional n Content-page Markdown actions use the projector's `rawMarkdownPath` and the site base, including full `index.md` routes. Copy fetches carry `?dsh-raw=1`; development script imports still belong to Vite. Keep clipboard work within its initiating gesture and page lifetime; the [Markdown actions decision](../.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md) owns browser limitations and verification. -The default-theme extension owns the Mermaid fullscreen viewer. Keep its enhancements separate from Markdown projection and preserve the original SVG. Route, language, theme, and rendered-SVG changes close the active view; theme disposal releases every observer, listener, and scroll lock. The [viewer decision](../.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md) explains SVG isolation and verification. +The default-theme extension shares one fullscreen modal and pan/zoom controller between Mermaid diagrams and standalone content images. Keep enhancements separate from Markdown projection and preserve source elements. Images must be loaded, have nonempty alt text, and occupy their own paragraph; linked, inline, decorative, and `data-no-zoom` images keep their existing interaction. Image views expose intrinsic size through the 100% control. Route, language, theme, and source replacement changes close the active view; theme disposal releases every observer, listener, and scroll lock. The [viewer decision](../.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md) explains SVG isolation and verification. Native code groups keep their radio controls in separate forms so copied search excerpts cannot clear the page's selected platform. Search excerpts and MPA builds display every command block because tab switching is unavailable; the [platform-tab decision](../.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md) owns the interaction and verification. diff --git a/website/tests/expected/image-viewer.en-US.html b/website/tests/expected/image-viewer.en-US.html new file mode 100644 index 0000000000..b8c1d3ec77 --- /dev/null +++ b/website/tests/expected/image-viewer.en-US.html @@ -0,0 +1,3 @@ + +
DeepSeek API settings
+DeepSeek API settings diff --git a/website/tests/expected/image-viewer.zh-CN.html b/website/tests/expected/image-viewer.zh-CN.html new file mode 100644 index 0000000000..39e04c2b23 --- /dev/null +++ b/website/tests/expected/image-viewer.zh-CN.html @@ -0,0 +1,3 @@ + +
DeepSeek API 配置
+DeepSeek API 配置 diff --git a/website/tests/image-viewer.spec.ts b/website/tests/image-viewer.spec.ts new file mode 100644 index 0000000000..3448107cb9 --- /dev/null +++ b/website/tests/image-viewer.spec.ts @@ -0,0 +1,350 @@ +/** @vitest-environment jsdom */ +/** Image loading, accessible controls, and ownership of the shared documentation viewer. */ +import assert from 'node:assert/strict' +import type { PanzoomOptions } from '@panzoom/panzoom' +import { getByRole, queryAllByRole, queryByRole, waitFor } from '@testing-library/dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ImageViewer } from '../.vitepress/theme/image-viewer.ts' +import { MediaViewer } from '../.vitepress/theme/media-viewer.ts' +import { installMermaidViewer, type MermaidViewer } from '../.vitepress/theme/mermaid-viewer.ts' + +const panzoom = vi.hoisted(() => ({ create: vi.fn<(element: HTMLElement, options: PanzoomOptions) => unknown>() })) +vi.mock('@panzoom/panzoom', () => ({ default: panzoom.create })) + +function controller(scale: number) { + return { + destroy: vi.fn(), setOptions: vi.fn(), zoom: vi.fn(), pan: vi.fn(), + zoomIn: vi.fn(), zoomOut: vi.fn(), zoomWithWheel: vi.fn(), getScale: vi.fn(() => scale), + } +} + +let viewer: ImageViewer | undefined +let mermaid: MermaidViewer | undefined +let media: MediaViewer +let language = 'en-US' +let overflow: string +const controllers: ReturnType[] = [] +const disconnects: ReturnType[] = [] +const dialogMethods = new Map(['showModal', 'close'].map(name => [ + name, Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, name), +])) + +beforeEach(() => { + panzoom.create.mockReset().mockImplementation((_element, options) => { + const instance = controller(options.startScale ?? 1) + controllers.push(instance) + return instance + }) + language = 'en-US' + overflow = document.body.style.overflow + document.body.innerHTML = '
' + document.body.style.overflow = 'auto' + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1032) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(632) + vi.spyOn(SVGSVGElement.prototype, 'viewBox', 'get').mockReturnValue({ baseVal: { width: 2000, height: 3000 } } as SVGAnimatedRect) + Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { configurable: true, value: function (this: HTMLDialogElement) { + this.open = true + this.querySelector('[autofocus]')?.focus() + } }) + Object.defineProperty(HTMLDialogElement.prototype, 'close', { configurable: true, value: function (this: HTMLDialogElement) { + this.open = false + this.dispatchEvent(new Event('close')) + } }) + vi.stubGlobal('ResizeObserver', class { + disconnect = vi.fn() + constructor() { disconnects.push(this.disconnect) } + observe(): void {} + }) + media = new MediaViewer(document, () => language) +}) + +afterEach(() => { + viewer?.dispose() + mermaid?.dispose() + media.close() + viewer = undefined + mermaid = undefined + controllers.length = 0 + disconnects.length = 0 + document.body.replaceChildren() + document.body.style.overflow = overflow + vi.restoreAllMocks() + vi.unstubAllGlobals() + for (const [name, descriptor] of dialogMethods) { + if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, name, descriptor) + else Reflect.deleteProperty(HTMLDialogElement.prototype, name) + } +}) + +function required(value: T | null | undefined): T { + assert(value != null) + return value +} + +function imageState(image: HTMLImageElement, complete = true) { + const state = { complete, width: 1600, height: 800, currentSrc: '' } + vi.spyOn(image, 'complete', 'get').mockImplementation(() => state.complete) + vi.spyOn(image, 'naturalWidth', 'get').mockImplementation(() => state.width) + vi.spyOn(image, 'naturalHeight', 'get').mockImplementation(() => state.height) + vi.spyOn(image, 'currentSrc', 'get').mockImplementation(() => state.currentSrc) + return state +} + +function render(complete = true) { + const container = document.createElement('p') + const image = document.createElement('img') + image.src = 'https://docs.example/provider.png' + image.alt = 'DeepSeek API settings' + container.append(image) + required(document.querySelector('main')).append(container) + return { image, container, state: imageState(image, complete) } +} + +function install(): ImageViewer { + viewer = new ImageViewer(document, () => language, media) + return viewer +} + +function entry(): HTMLElement { + return getByRole(document.body, 'button', { name: 'View image fullscreen: DeepSeek API settings' }) +} + +function open(): HTMLDialogElement { + entry().click() + return getByRole(document.body, 'dialog', { name: 'DeepSeek API settings' }) as HTMLDialogElement +} + +function enlarged(dialog: HTMLElement): HTMLImageElement { + return required(dialog.querySelector('.dsh-media-paper')?.shadowRoot?.querySelector('img')) +} + +describe('documentation image viewer', () => { + it('adds one entry after a delayed image loads and removes it when loading fails', async () => { + const { image, state } = render(false) + install() + expect(queryAllByRole(document.body, 'button')).toHaveLength(0) + state.complete = true + image.dispatchEvent(new Event('load')) + const trigger = entry() + required(viewer).refresh() + await waitFor(() => { expect(queryAllByRole(document.body, 'button')).toHaveLength(1) }) + expect(entry()).toBe(trigger) + state.width = 0 + image.dispatchEvent(new Event('error')) + expect(queryAllByRole(document.body, 'button')).toHaveLength(0) + expect(image.classList.contains('dsh-image-zoomable')).toBe(false) + image.click() + trigger.click() + expect(queryByRole(document.body, 'dialog')).toBeNull() + }) + + it('leaves linked, decorative, inline, grouped, and failed images unchanged', () => { + const root = required(document.querySelector('main')) + root.innerHTML = [ + '

Linked screenshot

', + '

', + '

', + '

Decorative

', + '

Decorative

', + '', + '

Opt out

', + '

Container opt out

', + '

Inline Badge

', + '

FirstSecond

', + 'Outside paragraph', + '

Diagram image

', + '

Failed

', + ].join('') + for (const image of root.querySelectorAll('img')) { + const state = imageState(image) + if (image.alt === 'Failed') state.height = 0 + } + const original = root.innerHTML + install() + expect(root.innerHTML).toBe(original) + expect(queryAllByRole(root, 'button')).toHaveLength(0) + }) + + it.each([ + { locale: 'en-US', title: 'DeepSeek API settings', name: 'View image fullscreen: DeepSeek API settings', trigger: 'image' }, + { locale: 'zh-CN', title: 'DeepSeek API 配置', name: '全屏查看图片:DeepSeek API 配置', trigger: 'button' }, + ])('opens the loaded image with accessible controls in $locale', async ({ locale, title, name, trigger }) => { + const { image, state } = render() + language = locale + image.alt = title + state.currentSrc = 'https://docs.example/provider@2x.png' + install() + const button = getByRole(document.body, 'button', { name }) + expect(button.getAttribute('aria-haspopup')).toBe('dialog') + if (trigger === 'image') image.click() + else button.click() + const dialog = getByRole(document.body, 'dialog', { name: title }) + const clone = enlarged(dialog) + expect(clone).not.toBe(image) + expect(clone.src).toBe(state.currentSrc) + expect(clone.alt).toBe(title) + expect(clone.style.width).toBe('1600px') + expect(clone.style.height).toBe('800px') + expect(clone.draggable).toBe(false) + expect(panzoom.create.mock.calls[0]?.[1]).toMatchObject({ startScale: 0.625, minScale: 0.3125 }) + expect(document.body.style.overflow).toBe('hidden') + await expect(`${button.outerHTML}\n${dialog.outerHTML}\n${clone.outerHTML}\n`) + .toMatchFileSnapshot(`./expected/image-viewer.${locale}.html`) + dialog.dispatchEvent(new Event('cancel', { cancelable: true })) + expect(queryByRole(document.body, 'dialog')).toBeNull() + expect(document.activeElement).toBe(button) + expect(document.body.style.overflow).toBe('auto') + }) + + it('centers the original size and includes its control in the keyboard focus cycle', () => { + render() + install() + const dialog = open() + const original = getByRole(dialog, 'button', { name: /^Original size \(100%\), currently / }) + expect(original.textContent).toBe('63%') + expect(original.getAttribute('aria-label')).toBe('Original size (100%), currently 63%') + original.click() + const current = required(controllers[0]) + expect(current.zoom).toHaveBeenCalledWith(1, { animate: false }) + expect(current.pan).toHaveBeenCalledWith(0, 0, { animate: false }) + current.getScale.mockReturnValue(1) + required(dialog.querySelector('.dsh-media-paper')).dispatchEvent(new Event('panzoomchange')) + expect(original.textContent).toBe('100%') + expect(original.getAttribute('aria-label')).toBe('Original size (100%), currently 100%') + const controls = ['Zoom out', 'Original size (100%), currently 100%', 'Zoom in', 'Fit view', 'Viewer help', 'Close'] + expect(document.activeElement).toBe(getByRole(dialog, 'button', { name: 'Close' })) + for (const name of controls) { + const event = new KeyboardEvent('keydown', { key: 'Tab', cancelable: true }) + dialog.dispatchEvent(event) + expect(event.defaultPrevented).toBe(true) + expect(document.activeElement).toBe(getByRole(dialog, 'button', { name })) + } + for (const name of controls.slice(0, -1).reverse().concat('Close')) { + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, cancelable: true })) + expect(document.activeElement).toBe(getByRole(dialog, 'button', { name })) + } + }) + + it.each(['image', 'diagram'])('replaces an active %s view without leaking its controls or scroll lock', (first) => { + render() + required(document.querySelector('main')).insertAdjacentHTML('beforeend', '
') + install() + mermaid = installMermaidViewer(document, () => language, media) + const imageTrigger = entry() + const diagramTrigger = getByRole(document.body, 'button', { name: 'View diagram fullscreen' }) + const [oldTrigger, newTrigger] = first === 'image' ? [imageTrigger, diagramTrigger] : [diagramTrigger, imageTrigger] + oldTrigger.click() + const oldDialog = getByRole(document.body, 'dialog') + const oldZoom = getByRole(oldDialog, 'button', { name: 'Zoom in' }) + newTrigger.click() + const dialog = getByRole(document.body, 'dialog') + expect(dialog).not.toBe(oldDialog) + expect(queryAllByRole(document.body, 'dialog')).toHaveLength(1) + expect(required(controllers[0]).destroy).toHaveBeenCalledOnce() + expect(required(disconnects[0])).toHaveBeenCalledOnce() + expect(document.body.style.overflow).toBe('hidden') + expect(document.activeElement).toBe(getByRole(dialog, 'button', { name: 'Close' })) + oldZoom.click() + expect(required(controllers[0]).zoomIn).not.toHaveBeenCalled() + if (first === 'image') required(viewer).refresh() + else required(mermaid).refresh() + expect(getByRole(document.body, 'dialog')).toBe(dialog) + expect(document.body.style.overflow).toBe('hidden') + expect(queryByRole(dialog, 'button', { name: /^Original size \(100%\), currently / }) === null).toBe(first === 'image') + getByRole(dialog, 'button', { name: 'Close' }).click() + expect(document.activeElement).toBe(newTrigger) + expect(document.body.style.overflow).toBe('auto') + expect(required(controllers[1]).destroy).toHaveBeenCalledOnce() + expect(required(disconnects[1])).toHaveBeenCalledOnce() + }) + + it.each(['src', 'srcset'])('closes stale content after %s changes and opens the replacement', async (attribute) => { + const { image, state } = render() + install() + const oldTrigger = entry() + open() + const replacement = 'https://docs.example/replacement.png' + if (attribute === 'srcset') state.currentSrc = replacement + image.setAttribute(attribute, attribute === 'srcset' ? `${replacement} 2x` : replacement) + await waitFor(() => { expect(queryByRole(document.body, 'dialog')).toBeNull() }) + expect(document.body.style.overflow).toBe('auto') + expect(required(controllers[0]).destroy).toHaveBeenCalledOnce() + oldTrigger.click() + expect(queryByRole(document.body, 'dialog')).toBeNull() + expect(entry()).not.toBe(oldTrigger) + expect(enlarged(open()).src).toBe(replacement) + }) + + it('closes stale content when currentSrc changes after the srcset mutation', async () => { + const { image, state } = render() + state.currentSrc = image.src + install() + const oldTrigger = entry() + const dialog = open() + const replacement = 'https://docs.example/provider@2x.png' + image.srcset = `${replacement} 2x` + await new Promise((resolve) => { queueMicrotask(resolve) }) + expect(getByRole(document.body, 'dialog')).toBe(dialog) + expect(enlarged(dialog).src).toBe(state.currentSrc) + + state.currentSrc = replacement + image.dispatchEvent(new Event('load')) + expect(queryByRole(document.body, 'dialog')).toBeNull() + expect(document.body.style.overflow).toBe('auto') + expect(required(controllers[0]).destroy).toHaveBeenCalledOnce() + expect(entry()).not.toBe(oldTrigger) + oldTrigger.click() + expect(queryByRole(document.body, 'dialog')).toBeNull() + expect(enlarged(open()).src).toBe(replacement) + }) + + it.each(['refresh', 'dispose', 'remove', 'error'] as const)('releases an open image on %s', async (action) => { + const { image, container, state } = render() + install() + const trigger = entry() + const dialog = open() + const zoom = getByRole(dialog, 'button', { name: 'Zoom in' }) + if (action === 'remove') container.remove() + else if (action === 'error') { + state.width = 0 + image.dispatchEvent(new Event('error')) + } else required(viewer)[action]() + await waitFor(() => { expect(queryByRole(document.body, 'dialog')).toBeNull() }) + expect(document.body.style.overflow).toBe('auto') + expect(required(controllers[0]).destroy).toHaveBeenCalledOnce() + expect(required(disconnects[0])).toHaveBeenCalledOnce() + zoom.click() + expect(required(controllers[0]).zoomIn).not.toHaveBeenCalled() + if (action === 'refresh') expect(document.activeElement).toBe(trigger) + else { + expect(image.classList.contains('dsh-image-zoomable')).toBe(false) + expect(container.classList.contains('dsh-image-container')).toBe(false) + image.click() + trigger.click() + expect(queryByRole(document.body, 'dialog')).toBeNull() + } + required(viewer).dispose() + expect(required(controllers[0]).destroy).toHaveBeenCalledOnce() + }) + + it('refreshes localized entry names and stops enhancing new and delayed images after disposal', async () => { + const ready = render() + const pending = render(false) + install() + const trigger = entry() + language = 'zh-CN' + ready.image.alt = 'API 配置' + required(viewer).refresh() + expect(getByRole(document.body, 'button', { name: '全屏查看图片:API 配置' })).toBe(trigger) + required(viewer).dispose() + pending.state.complete = true + pending.image.dispatchEvent(new Event('load')) + render() + await new Promise((resolve) => { queueMicrotask(resolve) }) + expect(queryAllByRole(document.body, 'button')).toHaveLength(0) + ready.image.click() + pending.image.click() + trigger.click() + expect(panzoom.create).not.toHaveBeenCalled() + }) +}) diff --git a/website/tests/mermaid-viewer.spec.ts b/website/tests/mermaid-viewer.spec.ts index 5d3710c46c..b6f6648e8f 100644 --- a/website/tests/mermaid-viewer.spec.ts +++ b/website/tests/mermaid-viewer.spec.ts @@ -4,6 +4,7 @@ import assert from 'node:assert/strict' import type { PanzoomOptions } from '@panzoom/panzoom' import { getByRole, queryAllByRole, queryByRole, waitFor } from '@testing-library/dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MediaViewer } from '../.vitepress/theme/media-viewer.ts' import { installMermaidViewer, type MermaidViewer } from '../.vitepress/theme/mermaid-viewer.ts' const panzoom = vi.hoisted(() => ({ @@ -14,6 +15,8 @@ const panzoom = vi.hoisted(() => ({ vi.mock('@panzoom/panzoom', () => ({ default: panzoom.create })) let viewer: MermaidViewer | undefined +let media: MediaViewer +let locale = 'en-US' let resize: (() => void) | undefined let viewportWidth = 1032 let viewportHeight = 632 @@ -24,6 +27,8 @@ const dialogMethods = new Map(['showModal', 'close'].map(name => [ beforeEach(() => { vi.clearAllMocks() + locale = 'en-US' + media = new MediaViewer(document, () => locale) panzoom.create.mockImplementation((_element, options) => { panzoom.getScale.mockReturnValue(options.startScale ?? 1) return panzoom @@ -55,6 +60,7 @@ beforeEach(() => { afterEach(() => { viewer?.dispose() + media.close() viewer = undefined resize = undefined document.body.replaceChildren() @@ -95,7 +101,8 @@ describe('documentation Mermaid viewer', () => { h1.textContent = heading required(document.querySelector('.vp-doc')).prepend(h1) } - viewer = installMermaidViewer(document, () => language) + locale = language + viewer = installMermaidViewer(document, () => locale, media) getByRole(document.body, 'button').click() const dialog = getByRole(document.body, 'dialog', { name: expected }) const title = required(document.getElementById(required(dialog.getAttribute('aria-labelledby')))) @@ -103,7 +110,7 @@ describe('documentation Mermaid viewer', () => { }) it('adds one entry only after an SVG with usable dimensions renders', async () => { - viewer = installMermaidViewer(document, () => 'en-US') + viewer = installMermaidViewer(document, () => locale, media) expect(queryAllByRole(document.body, 'button')).toHaveLength(0) render('0 0 0 3000') viewer.refresh() @@ -124,10 +131,10 @@ describe('documentation Mermaid viewer', () => { it('fits the natural SVG into the viewport and isolates copied styles and fragment IDs', () => { const source = render() const original = source.outerHTML - viewer = installMermaidViewer(document, () => 'en-US') + viewer = installMermaidViewer(document, () => locale, media) const dialog = open() expect(panzoom.create.mock.calls[0]?.[1]).toMatchObject({ startScale: 0.2, minScale: 0.1 }) - const paper = required(dialog.querySelector('.dsh-diagram-paper')) + const paper = required(dialog.querySelector('.dsh-media-paper')) const clone = required(paper.shadowRoot?.querySelector('svg')) expect(clone.style.width).toBe('2000px') expect(clone.style.height).toBe('3000px') @@ -148,13 +155,13 @@ describe('documentation Mermaid viewer', () => { it('connects zoom, wheel, keyboard panning and fit without dismissing a canvas click', () => { render() - viewer = installMermaidViewer(document, () => 'en-US') + viewer = installMermaidViewer(document, () => locale, media) const dialog = open() getByRole(dialog, 'button', { name: 'Zoom in' }).click() getByRole(dialog, 'button', { name: 'Zoom out' }).click() expect(panzoom.zoomIn).toHaveBeenCalledOnce() expect(panzoom.zoomOut).toHaveBeenCalledOnce() - const viewport = required(dialog.querySelector('.dsh-diagram-viewport')) + const viewport = required(dialog.querySelector('.dsh-media-viewport')) const wheel = new WheelEvent('wheel', { deltaY: -100 }) viewport.dispatchEvent(wheel) expect(panzoom.zoomWithWheel).toHaveBeenCalledWith(wheel) @@ -170,7 +177,7 @@ describe('documentation Mermaid viewer', () => { it('wraps keyboard focus through the viewer controls in both directions', () => { render() - viewer = installMermaidViewer(document, () => 'en-US') + viewer = installMermaidViewer(document, () => locale, media) const dialog = open() const close = getByRole(dialog, 'button', { name: 'Close' }) const zoomOut = getByRole(dialog, 'button', { name: 'Zoom out' }) @@ -187,10 +194,10 @@ describe('documentation Mermaid viewer', () => { it('shows zoom changes and reveals help on demand without retaining listeners after close', () => { render() - viewer = installMermaidViewer(document, () => 'en-US') + viewer = installMermaidViewer(document, () => locale, media) const dialog = open() - const scale = required(dialog.querySelector('.dsh-diagram-scale')) - const paper = required(dialog.querySelector('.dsh-diagram-paper')) + const scale = required(dialog.querySelector('.dsh-media-scale')) + const paper = required(dialog.querySelector('.dsh-media-paper')) expect(scale.textContent).toBe('20%') panzoom.getScale.mockReturnValue(0.75) paper.dispatchEvent(new Event('panzoomchange')) @@ -213,7 +220,7 @@ describe('documentation Mermaid viewer', () => { it.each(['button', 'cancel', 'refresh', 'dispose'] as const)('releases resources on %s and restores focus and scrolling', (method) => { render() - viewer = installMermaidViewer(document, () => 'en-US') + viewer = installMermaidViewer(document, () => locale, media) const trigger = getByRole(document.body, 'button') const dialog = open() const zoom = getByRole(dialog, 'button', { name: 'Zoom in' }) @@ -233,8 +240,7 @@ describe('documentation Mermaid viewer', () => { it('closes a replaced diagram and permits repeated opens with the updated locale', async () => { render() - let locale = 'en-US' - viewer = installMermaidViewer(document, () => locale) + viewer = installMermaidViewer(document, () => locale, media) open() render('0 0 400 200') await waitFor(() => { @@ -252,7 +258,7 @@ describe('documentation Mermaid viewer', () => { it('closes when route content is removed and stops enhancing after disposal', async () => { render() - viewer = installMermaidViewer(document, () => 'en-US') + viewer = installMermaidViewer(document, () => locale, media) open() required(document.querySelector('.mermaid')).remove() await waitFor(() => { diff --git a/website/tests/page-markdown-actions.spec.ts b/website/tests/page-markdown-actions.spec.ts index 5a98fa84f0..966e4ae961 100644 --- a/website/tests/page-markdown-actions.spec.ts +++ b/website/tests/page-markdown-actions.spec.ts @@ -12,6 +12,8 @@ vi.mock('vitepress/theme', () => ({ default: { Layout: { setup: (_props: unknown, { slots }: { slots: Slots }) => () => h('main', null, slots['doc-before']?.()), } } })) vi.mock('../.vitepress/theme/mermaid-viewer.ts', () => ({ installMermaidViewer: () => ({ refresh() {}, dispose() {} }) })) +vi.mock('../.vitepress/theme/image-viewer.ts', () => ({ ImageViewer: class { refresh() {} dispose() {} } })) +vi.mock('../.vitepress/theme/media-viewer.ts', () => ({ MediaViewer: class { close() {} } })) const data = { lang: ref('en-US'), isDark: ref(false),