feat(sandbox-windows-acl): ship a diagnosis skill for ACL denials

A denial this backend cannot explain — the confined child cannot open an object it
was granted, or provisioning itself fails with Win32 5 — reached the user as a bare
"Access is denied". The README documented both causes but shipped no way to tell
them apart.

Add the `diagnose-windows-sandbox-acl` skill. Its script reads unless a repair switch
is passed, and classifies the failing path and its ancestors before anything
changes; its two repair modes are the only supported ACL edits. `-Fix` removes a
foreign package-SID allow ACE, and `-GrantFullControl` supplies the
WRITE_DAC/WRITE_OWNER pair the mandatory-label write needs without ever changing an
owner. Both require `-AllowRoot`, refuse a target outside it, and refuse to run
together.

`dsh-sandbox-local` registers the skill on Windows through `ctx.inject(['skills'],
...)`, so the skill registry stays optional and the sandbox never depends on it. The
assets ship in this package's `files` list, which the workspace-constraints gate now
pins alongside the runner and emitted-type entries.

Tests cover the script's verdicts (foreign ACE, healthy object, inert capability
SID, inert DENY ACE), its surgical removal, the grant's owner invariant, and both
refusals; the catalog spec covers registration and disposal.
This commit is contained in:
Huanqi Cao committed 2026-09-25 13:17:36 +08:00
1 parent 477b4f4205
commit 1d56bd5628
14 files changed
+870 -15

No files matched your search

+8 -1
View File
@@ -37,7 +37,7 @@ import z from '@deepseek-ai/schemastery'
import { SandboxProvider, SandboxUnavailableError, canonicalPath } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { AclWriteGrant, assertTempRootOutsideWorkspace, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
import { AclWriteGrant, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
@@ -295,6 +295,13 @@ export class LocalSandboxProvider extends SandboxProvider {
this.configuredRunnerFailureSignatures = runnerFailureSignatures
this.probeTimeoutMs = config.probeTimeoutMs as number
assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs)
// The bundled diagnosis skill explains this backend's ACL failures, so it is
// registered wherever the backend composes. `ctx.inject` keeps `skills`
// optional: the sandbox must not require the skill registry.
/* v8 ignore next 3 -- Windows-only registration; the Linux coverage lane cannot take this branch */
if (process.platform === 'win32') {
ctx.inject(['skills'], skillsCtx => registerAclDiagnosisSkill(skillsCtx))
}
// The temp grants are revoked with the provider: a clean server
// shutdown leaves no temp ACEs behind (workspace ACEs stand by design —
// the reuse cache; an unclean shutdown leaves them for the next
@@ -27,8 +27,8 @@
en: 289e65bddf20dd79
zh: 44b9fcedf944546a
/deepseek-ai-dsh-sandbox-windows-acl/use-this-package/failures-and-recovery:
en: f93372e784cda63e
zh: f8371114f3481550
en: bd847a7890748b9d
zh: 25ef02776687d8c3
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation:
en: c2cde86c85c2c619
zh: a29a114e591f9d3f
@@ -51,14 +51,14 @@
en: 7850da9522bf984b
zh: a9f2273a85bafcf9
/deepseek-ai-dsh-sandbox-windows-acl/model-experience:
en: c94416e26fec28cd
zh: a16f435e8afbdf30
en: bbb19594a2c2e4d4
zh: 075a65d9b9182aad
/deepseek-ai-dsh-sandbox-windows-acl/model-experience/kv-cache-effect:
en: 796af37d6f6add68
zh: 1e0dc5a58e5456be
en: 8dc637e4c015ff15
zh: 10db9e10111108cc
/deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work:
en: 2b6b967c62766d47
zh: 0faf9ec390504967
en: 2311808c0ded8564
zh: 845ddb2408c861fd
/deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work/dev-note:
en: cc273320f6a1beaf
zh: 5c57a312ddc50651
@@ -75,6 +75,8 @@ Temp isolation is per live session/workspace pair: sessions sharing a workspace
`init()` throws on any Win32 failure — the child is never spawned unrestricted. A runner that fails before executing the command prints `windows-acl-run: <detail>` to stderr and exits 127, which the seam's runner-failure rules classify as a broken sandbox rather than a denial. Cleanup is best-effort by design: `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`.
A denial this backend cannot explain — the child cannot open an object it was granted, or the provisioning call itself fails — is diagnosed by the `diagnose-windows-sandbox-acl` skill bundled in `assets/`, whose script reads unless a repair switch is passed and classifies the failing path and its ancestors before anything changes. `registerAclDiagnosisSkill` contributes it to the session catalog wherever `dsh-sandbox-local` composes on Windows, and the script's `-Fix` and `-GrantFullControl` modes are the only supported repairs: both require `-AllowRoot`, refuse any target outside it, never change an owner, and need an unconfined caller because a confined child can neither open an object carrying a package SID nor write a DACL.
-----
<a id="understand-the-implementation"></a>
@@ -156,11 +158,11 @@ Start with the subsystem reference for the shared vocabulary, then the provider
<a id="model-experience"></a>
## Model Experience
Indirectly, through [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../shell/pwsh-sandbox/README.md), and their tools, which render this backend's partial-enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and `sandbox-local` owns runner selection.
Indirectly, through [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../shell/pwsh-sandbox/README.md), and their tools, which render this backend's partial-enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and `sandbox-local` owns runner selection. On Windows this package also contributes one catalog entry, the bundled `diagnose-windows-sandbox-acl` skill, which is where the model learns to diagnose a denial the tool layer can only report.
#### KV Cache effect
None directly; the denial surface belongs to the tool layer.
One extra catalog entry on Windows: the skill's `description` (500 characters) rides in the catalog, while its body and script load only when the model invokes it. The denial surface itself stays in the tool layer.
## Known Limitations and Deferred Work
@@ -178,6 +180,7 @@ These limits define when the backend is a poor fit or needs special operational
- **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.
- **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.
- **The bundled diagnosis skill needs an unconfined caller** — both the classification and the repairs read or write security descriptors a confined child cannot reach, so the session must escalate that one call; with no approval channel the escalation fails closed and the model can only hand the user the exact command to run.
- **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.
<a id="dev-note"></a>
@@ -75,6 +75,8 @@ rmSync(tempDir, { recursive: true, force: true })
`init()` 在任何 Win32 失败时抛出——子进程绝不会不受限制地 spawn。执行命令前失败的 runner 会向 stderr 打印 `windows-acl-run: <detail>` 并以 127 退出,seam 的 runner 失败规则将其归类为损坏的沙箱,而非拒绝。清理按设计尽力而为:`dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`。
此后端无法解释的拒绝——子进程打不开本已授权的对象,或授权调用本身失败——交给 `assets/` 中随包发布的 `diagnose-windows-sandbox-acl` 技能诊断:该脚本除非传入修复开关,否则只读,并先对待查路径及其祖先做出分类,再动任何东西。只要 `dsh-sandbox-local` 在 Windows 上被组合,`registerAclDiagnosisSkill` 就会把它注册进会话目录;脚本的 `-Fix` 与 `-GrantFullControl` 是唯一受支持的修复,两者都要求 `-AllowRoot`、拒绝其外的任何目标、绝不更改所有者,并且需要不受限的调用者——受限子进程既打不开带包 SID 的对象,也写不了 DACL。
-----
<a id="understand-the-implementation"></a>
@@ -156,11 +158,11 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服
<a id="model-experience"></a>
## 模型体验
间接地通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.zh.md)、[`dsh-pwsh-sandbox`](../../shell/pwsh-sandbox/README.zh.md) 及其工具呈现;它们渲染此后端的部分强制执行与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.zh.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本、`sandbox-local` 拥有 runner 选择。
间接地通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.zh.md)、[`dsh-pwsh-sandbox`](../../shell/pwsh-sandbox/README.zh.md) 及其工具呈现;它们渲染此后端的部分强制执行与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.zh.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本、`sandbox-local` 拥有 runner 选择。在 Windows 上本包还贡献一个目录条目——随包发布的 `diagnose-windows-sandbox-acl` 技能,模型正是从它学会诊断工具层只能上报的拒绝。
#### KV Cache 影响
无直接影响;拒绝面属于工具层。
Windows 上多一个目录条目:技能的 `description`(500 字符)随目录进入上下文,其正文与脚本仅在模型调用该技能时加载。拒绝面本身仍属于工具层。
## 已知限制与延期工作
@@ -178,6 +180,7 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服
- **读侧隔离与网络策略不在范围内**——`WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。
- **读取会被其他基于 AppContainer 的工具以包 SID 授权过的对象挡住。** 在本机上,当文件的 DACL 携带针对包 SID(`S-1-15-2-…`)的 ACE 时,Low 完整性的令牌无法访问它——即使同一份 DACL 同时向用户授予完全控制、向 Everyone 授予读取(已观测:只给新文件加这一条 ACE 即可复现拒绝,补授 Everyone 读取无法解除,而同样内容复制到别处仍可读)。其背后的内核规则尚未确证,也不由本包掌控;以 AppContainer 自我隔离的工具正是会写入这类 ACE,因此被它们标记过的目录树对本后端的子进程将不可读。移除外来 ACE(或重新安装受影响的目录树)即可恢复访问。
- **宽目录与 FAT 卷警告已推迟;FAT 类残留未经验证。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标不存储安全描述符;其有效完整性标签由系统分配而非记录在对象上,因此标签层在该处的行为未经测试。FAT 仍被视为遗留残留。
- **随包发布的诊断技能需要不受限的调用者。** 无论分类还是修复,都要读写受限子进程够不到的安全描述符,因此会话必须为那一次调用升权;没有审批通道时升权 fail-closed,模型只能把确切的命令交给用户自行执行。
- **PowerShell 语言模式因受限模式而异。** 在 `read-only` 下,PowerShell 无法在临时目录中创建 AppLocker 探针文件,因此会保守地以 ConstrainedLanguage 启动(`Add-Type`、非核心 .NET 静态调用、COM 与反射失败);交付的 `workspace-write` 路径可让探针完成,因此除非主机范围的 WDAC/AppLocker 策略另有规定,否则 pwsh 保持 FullLanguage,而直接使用 `AclSandbox` 并配置 `tempDir: null` 时则没有这一保证。这一区别属于 PowerShell 启动行为,不是 ACL 写入边界的一部分。
<a id="dev-note"></a>
@@ -0,0 +1,120 @@
---
name: diagnose-windows-sandbox-acl
description: 'Use on Windows when the DSH sandbox denies something it promised: cannot write or list in the workspace, or read a path the user should be able to read. Run the bundled script over the failing path and its ancestors; it only reads without a repair switch. It separates an AppContainer package-SID ACE, which it removes under an explicit root, from an unmet ownership or permission precondition, which it repairs with a full-control grant. Never change an owner or edit ACLs outside the script.'
---
# Diagnose Windows sandbox ACL failures
A denial is only worth diagnosing when it contradicts what the active mode promises. Establish that first: repairing a denial the sandbox is supposed to produce means weakening confinement for nothing.
## Step 0 — Decide whether this denial is unexpected
Enter the diagnosis only for these:
| Symptom | Why it contradicts the promise |
|---|---|
| Cannot write inside the workspace (root or any subdirectory) | `workspace-write` promises writes there |
| Cannot enumerate inside the workspace (`dir` prints nothing for a directory that has entries) | same |
| Anything the signed-in user should plainly be able to read cannot be read | reads are never confined in either restricted mode |
| Only the workspace **root** is denied while its subdirectories work normally | points at one object, not at the whole tree |
Strong but not required corroboration: `Test-Path` returns true while reads fail; `dir` reports an empty directory that is not empty; `cmd` cannot read a file that .NET reads fine.
Stop and explain instead of repairing for these — they are the confinement working as documented:
| Symptom | Why it is expected | What to do instead |
|---|---|---|
| Writing or deleting **outside** the workspace under `workspace-write` | the fence is doing its job | escalate that one call, or write inside the workspace |
| Any write under `read-only` | the mode itself | escalate that one call, or switch mode |
| `spawn EPERM` when a confined command captures a grandchild's output through piped stdio | named-pipe opens are denied in both restricted modes (a Win32 default-SD property, not a permission mistake); anonymous pipes and inherited stdio still work | escalate that one call, or restructure the command to avoid piping |
| `Add-Type`, `[System.IO.*]::`, COM, or reflection failing with "only core types" | `read-only` pwsh runs in ConstrainedLanguage | not a permission problem; escalate or use `workspace-write` |
Then split "cannot write in the workspace" by shape, because the two shapes have different causes:
- **Uniformly denied** — the root and every subdirectory fail together: the sandbox could not provision its grant.
- **Only the root denied** — subdirectories are fine: one object carries something that blocks it.
## Step 1 — Diagnose read-only before changing anything
Run the bundled script over the failing path. It reads ACLs with .NET and native `icacls`, changes nothing, and prints machine-parseable lines:
```powershell
pwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path <failing-path>
```
It reports, for the target and every ancestor: any AppContainer package SID (`S-1-15-2-*`) as explicit or inherited, other `S-1-15-*` SIDs (reported but inert), the owner, whether the current user holds `WRITE_DAC` and `WRITE_OWNER`, whether the ACL could be read at all, and the caller's integrity level plus any sandbox markers in the environment.
The failing API names the family. DSH's fail-closed errors carry the API and the exact Win32 code, so read the error rather than guessing:
| Error | Meaning |
|---|---|
| `GetNamedSecurityInfoW` … (Win32 5) | the object could not be **read** — either it carries a package SID and the caller is below Medium, or the caller has no access to it at all |
| `SetNamedSecurityInfoW` … (Win32 5) | the object was read but not written — the caller lacks `WRITE_DAC`, `WRITE_OWNER`, or both |
| `grantWrite` succeeded, yet the confined child still cannot use the directory | the grant landed but the child cannot open the object |
The script needs a caller that can open the object. `read-only` pwsh cannot run it at all, because ConstrainedLanguage blocks the .NET calls it needs; under `workspace-write` the confined child cannot open an object carrying a package SID, so the diagnosis comes back `READABLE=False` for the very object that caused the problem. In both modes the diagnosis needs an unconfined caller, so escalate once and run the diagnosis together with any repair in that same call (Step 4) — rather than concluding that the path is unreadable or that the failure is something else.
## Step 2 — Read the verdict
| Package SID present | Owner is the current user | Verdict | Action |
|---|---|---|---|
| yes | yes | a foreign AppContainer ACE is the blocker | remove the ACE (Step 4) |
| yes | no | both problems at once | grant full control first, then remove the ACE; the grant needs Windows elevation |
| no | yes, and the DACL lacks `WRITE_OWNER` | a precondition DSH documents is unmet | grant full control (Step 4); ownership already carries `WRITE_DAC`, so no elevation is needed |
| no | no | a precondition this caller cannot repair | hand the user the single command to run from an elevated terminal (Step 4) |
| no | yes and the DACL is complete | not this class | say so and stop; report what was actually found |
## Step 3 — Explain, and apologize
Say what the finding is and what it will cost before doing anything.
Apologize on the harness's behalf, in the language the user is writing in, in one or two sentences, then move to the technical explanation. The gap being owned is ours: the harness neither notices the conflict when it provisions the grant nor gives any direction when the denial surfaces, so the user only sees a bare "Access is denied" and has to guess. Do not apologize before the cause is confirmed, do not repeat it, do not blame another tool in the apology (the cause belongs in the explanation), and do not let it replace the explanation.
Two things must reach the user either way:
- **Which shape this is** — one object carrying a foreign ACE, or a directory whose ownership/DACL does not satisfy DSH's documented precondition.
- **That removing a foreign ACE may be undone** — if whatever tool placed it runs again with this directory as its root, it will place it again. The repair is not a permanent fix.
## Step 4 — Repair, or hand over steps
Two repairs exist, and both run through the same script so that no ACL is edited by hand.
**Remove a foreign package-SID ACE.** It grants nothing to anyone yet blocks every reader below Medium:
```powershell
pwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path <path> -AllowRoot <containing-root> -Out <dir> -Fix
```
**Grant the current user full control when the precondition is unmet:**
```powershell
pwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path <path> -AllowRoot <containing-root> -Out <dir> -GrantFullControl
```
Full control carries both rights this needs: `WRITE_DAC` for the DACL and `WRITE_OWNER` for the mandatory label DSH writes in the same call. **Taking ownership alone does not work** — it yields only `WRITE_DAC`, so the label write still fails. Never change an object's owner, and never "replace permissions on all child objects": that erases deny ACEs somebody set deliberately.
Both modes require `-AllowRoot` and refuse any target outside it, are dry-run unless the repair switch is passed, write a native `icacls` backup plus a rollback command to `-Out`, and re-read the object to confirm both that the intended change landed and that every other ACE — the mandatory label included — survived unchanged. Both also write a DACL, which the confined child cannot do, so run the diagnosis and the repair inside one escalated call rather than spending two approvals on the same directory.
When the caller owns the directory, the missing piece is only `WRITE_OWNER`: ownership already carries `WRITE_DAC`, so the script repairs it unelevated. Windows elevation is needed only when the owner is somebody else, because an unelevated caller cannot write the DACL of a directory it does not own; the script reports that case as a failed grant together with the one command to run.
Do not reach for `sandbox_permissions` to solve that token problem. Escalation is the right lever for giving the script an unconfined caller (Step 1) and the wrong one for writing a DACL the caller has no right to write, because it widens the sandbox and not the Windows token. Even at `danger-full-access` the harness still runs unelevated with `Administrators` marked deny-only, so it has no `WRITE_DAC` on a directory owned by Administrators, and asking for the escalation only spends an approval on the wrong lever.
Do not make the harness trigger UAC either. An agent-initiated `runas` is a privilege-escalation path, the elevated child would run outside both the sandbox and the approval ledger the session log is built from, and a UAC prompt cannot be routed through this harness's approval channel. Hand the user the single exact command to run from an elevated terminal, and say plainly what it changes.
Never recommend running the whole agent elevated instead: that is a downgrade, and the community habit of "just run it as administrator" trades the isolation away for a working prompt.
Also stop short when the directory cannot be repaired this way at all, and say which applies: the owner is a system principal such as `TrustedInstaller`, the path is one the user has no business owning, or the volume is not ACL-capable. Moving the workspace is then the honest answer.
## Step 5 — Verify against the original failure
Re-run the operation that failed, from the same confined context. The repair is only done when that operation succeeds. If it still fails, report the new error verbatim rather than starting another round of changes.
## Step 6 — Report recurrence
Tell the user the condition that brings it back, so they can recognize it instead of re-diagnosing it.
## Never
- Never write, create, or delete a file to *test* permissions. Writing to probe is how an existing file gets clobbered, and it answers nothing the read-only diagnosis does not. (This has already happened once in this repository: a probe overwrote a workspace-root `AGENTS.md` that no version control could restore.)
- Never edit ACLs by hand — no `Set-Acl` from a constructed SDDL, no `icacls` surgery outside the bundled script. A hand-built security descriptor also marks the SACL for writing, which fails with a privilege error that looks like a bug.
- Never repair anything outside the directory being diagnosed, and never widen `-AllowRoot` beyond the tree that legitimately contains the failing path — its purpose is to keep a repair from reaching a neighbour of the thing that failed.
@@ -0,0 +1,392 @@
<#
.SYNOPSIS
Read-only diagnosis, and whitelisted repair, of Windows ACL objects that the
DSH sandbox cannot open.
.DESCRIPTION
A denial inside the DSH sandbox is worth diagnosing only when it contradicts
what the active mode promises: writes inside the workspace, or reads that the
signed-in user should plainly have. This script reports why such an object is
unreadable or unopenable, and repairs exactly one class of cause.
Diagnosis (the default) never writes anything: it reads each requested path
and every ancestor with .NET and native icacls, and prints machine-parseable
lines for the caller to interpret.
Repair (-Fix) removes ONLY allow ACEs whose SID is an AppContainer package SID
(S-1-15-2-*). Those grant nothing to anyone on this machine while making the
object unopenable to every caller below Medium integrity. Repair requires
-AllowRoot, refuses any target outside it, refuses reparse points and
dangerous roots, records an SDDL/icacls backup, and re-reads each object to
confirm that no other ACE and no mandatory label changed.
The script never creates, deletes, or writes the contents of any file. Its
only footprint outside the ACLs it is asked to repair is the backup it writes
under -Out.
.PARAMETER Path
One or more failing paths. Each is diagnosed together with its ancestors.
.PARAMETER AllowRoot
Required with -Fix. Every repaired object must be inside this directory.
.PARAMETER Out
Required with -Fix. Directory receiving the ACL backup and rollback notes.
.PARAMETER Fix
Remove S-1-15-2-* allow ACEs. Absent means diagnose only.
.PARAMETER GrantFullControl
Grant the current user full control on the requested path. Full control is what
the documented precondition needs: it carries WRITE_DAC for the DACL and
WRITE_OWNER for the mandatory label DSH writes in the same call, while taking
ownership supplies only WRITE_DAC. The owner is never changed. Mutually
exclusive with -Fix.
.EXAMPLE
pwsh -File diagnose-windows-sandbox-acl.ps1 -Path 'C:\Users\me\.ssh'
.EXAMPLE
pwsh -File diagnose-windows-sandbox-acl.ps1 -Path '<workspace>\.dsh-acl-fixture-x\case1' -AllowRoot '<workspace>\.dsh-acl-fixture-x' -Out '<workspace>\.dsh-acl-fixture-x\report' -Fix
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string[]]$Path,
[string]$AllowRoot,
[string]$Out,
[switch]$Fix,
[switch]$GrantFullControl
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$PACKAGE_SID = '^S-1-15-2-'
$LOW_LABEL_SID = 'S-1-16-4096'
function Write-Line { param([string]$Text) Write-Output $Text }
# Unwrap nested exceptions: PowerShell wraps Win32 failures, and only the inner
# exception carries the real HRESULT the caller needs to classify the failure.
function Get-HResultChain {
param([System.Exception]$Exception)
$chain = @()
$e = $Exception
while ($null -ne $e) {
$chain += ('{0}=0x{1:X8}/win32={2}: {3}' -f $e.GetType().Name, $e.HResult, ($e.HResult -band 0xFFFF), $e.Message)
$e = $e.InnerException
}
return ($chain -join ' <- ')
}
function Get-CurrentIdentity {
return [System.Security.Principal.WindowsIdentity]::GetCurrent()
}
# Integrity level and sandbox markers decide the symptom shape: the same foreign
# ACE fails a grant when the caller is below Medium and merely blocks the child
# when the caller is not.
# The integrity level lives in the token's group list, which .NET filters out of
# WindowsIdentity.Groups, so read it through GetTokenInformation. Add-Type compiles
# in-process on PowerShell 7. Never spawn whoami.exe for this: inside a below-Medium
# token that process fails to initialize (STATUS_DLL_INIT_FAILED, 0xc0000142) and
# raises an application-error dialog on the user's desktop.
function Get-IntegritySid {
try {
if (-not ('DshTokenInfo' -as [type])) {
Add-Type -Namespace Dsh -Name TokenInfo -MemberDefinition @'
[StructLayout(LayoutKind.Sequential)]
public struct SID_AND_ATTRIBUTES { public IntPtr Sid; public uint Attributes; }
[StructLayout(LayoutKind.Sequential)]
public struct TOKEN_MANDATORY_LABEL { public SID_AND_ATTRIBUTES Label; }
[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool GetTokenInformation(IntPtr token, int infoClass, IntPtr info, uint length, out uint returned);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern IntPtr GetSidSubAuthority(IntPtr sid, uint index);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern IntPtr GetSidSubAuthorityCount(IntPtr sid);
public static int Level(IntPtr token) {
uint length;
GetTokenInformation(token, 25, IntPtr.Zero, 0, out length);
if (length == 0) { return -1; }
IntPtr buffer = Marshal.AllocHGlobal((int)length);
try {
if (!GetTokenInformation(token, 25, buffer, length, out length)) { return -1; }
IntPtr sid = Marshal.PtrToStructure<TOKEN_MANDATORY_LABEL>(buffer).Label.Sid;
byte count = Marshal.ReadByte(GetSidSubAuthorityCount(sid));
return Marshal.ReadInt32(GetSidSubAuthority(sid, (uint)(count - 1)));
} finally { Marshal.FreeHGlobal(buffer); }
}
'@
}
$level = [Dsh.TokenInfo]::Level([System.Security.Principal.WindowsIdentity]::GetCurrent().Token)
switch ($level) {
0 { return 'S-1-16-0 (Untrusted)' }
4096 { return 'S-1-16-4096 (Low)' }
8192 { return 'S-1-16-8192 (Medium)' }
12288 { return 'S-1-16-12288 (High)' }
16384 { return 'S-1-16-16384 (System)' }
default { return 'unknown' }
}
} catch {
return 'unknown'
}
}
function Get-CallerFacts {
$sid = Get-IntegritySid
$markers = @()
foreach ($name in (Get-ChildItem env: | Select-Object -ExpandProperty Name)) {
if ($name -like 'SBX_*' -or $name -like 'CODEX_*') { $markers += $name }
}
return @{ Integrity = $sid; Markers = $markers }
}
function Test-DangerousRoot {
param([string]$FullPath)
$trimmed = $FullPath.TrimEnd('\')
if ($trimmed -match '^[A-Za-z]:$') { return 'drive root' }
if ($trimmed -ieq ([System.IO.Path]::GetFullPath($env:USERPROFILE).TrimEnd('\'))) { return 'user profile root' }
if ($trimmed -ieq ([System.IO.Path]::GetFullPath($env:WINDIR).TrimEnd('\'))) { return 'Windows directory' }
return $null
}
function Test-UnderRoot {
param([string]$FullPath, [string]$Root)
$r = [System.IO.Path]::GetFullPath($Root).TrimEnd('\')
$c = [System.IO.Path]::GetFullPath($FullPath)
return $c.StartsWith($r + '\', [System.StringComparison]::OrdinalIgnoreCase)
}
function Get-ObjectFacts {
param([string]$FullPath, [System.Security.Principal.WindowsIdentity]$Identity, [string]$MeSid)
$facts = [ordered]@{
Object = $FullPath
Readable = $false
Error = ''
PackageAces = @()
OtherAppContainerSids = @()
Owner = ''
OwnerIsMe = $false
MyRights = ''
HasWriteDac = $false
HasWriteOwner = $false
LowLabel = $false
AclLines = @()
}
try {
# Get-Acl reads the security descriptor directly. On .NET Core the
# FileSystemInfo.GetAccessControl() form is an extension method, which
# PowerShell cannot invoke with instance syntax.
$acl = Get-Acl -LiteralPath $FullPath
$facts.Readable = $true
} catch {
$facts.Error = Get-HResultChain -Exception $_.Exception
return $facts
}
foreach ($rule in $acl.Access) {
# Orphaned and capability SIDs have no NTAccount form and Translate throws on
# them, so fall back to the reference's own text.
$reference = $rule.IdentityReference
$sid = if ($reference -is [System.Security.Principal.SecurityIdentifier]) { $reference.Value }
else { try { $reference.Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { $reference.Value } }
if ($sid -match $PACKAGE_SID -and $rule.AccessControlType -eq 'Allow') {
$facts.PackageAces += ('SID={0} INHERITED={1} INHERITABLE={2} RIGHTS={3}' -f $sid, $rule.IsInherited, $rule.InheritanceFlags, $rule.FileSystemRights)
} elseif ($sid -match '^S-1-15-' -and $sid -notmatch $PACKAGE_SID) {
$facts.OtherAppContainerSids += $sid
}
if ($sid -eq $MeSid -and $rule.AccessControlType -eq 'Allow') {
$facts.MyRights = ($facts.MyRights + ';' + [string]$rule.FileSystemRights).Trim(';')
# FileSystemRights names these ChangePermissions (WRITE_DAC) and
# TakeOwnership (WRITE_OWNER); there is no WriteDac/WriteOwner member.
if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::ChangePermissions) -ne 0) { $facts.HasWriteDac = $true }
if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::TakeOwnership) -ne 0) { $facts.HasWriteOwner = $true }
}
}
try {
$facts.Owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
$facts.OwnerIsMe = ($facts.Owner -eq $MeSid)
# Ownership carries an implicit READ_CONTROL and WRITE_DAC: a target the caller
# owns stays writable even when no ACE names the caller with WriteDac.
if ($facts.OwnerIsMe) { $facts.HasWriteDac = $true }
} catch {
$facts.Owner = 'unreadable'
}
# The mandatory label lives in the SACL. Reading the SACL needs a privilege while
# icacls prints the label without one, but icacls renders it by name, which is
# localized: try the integrity SID first and keep the English name as a fallback.
try {
$facts.AclLines = @(icacls $FullPath)
$text = $facts.AclLines -join "`n"
$facts.LowLabel = ($text -match $LOW_LABEL_SID) -or ($text -match 'Mandatory Label')
} catch {
$facts.LowLabel = $false
}
return $facts
}
function Get-Ancestors {
param([string]$FullPath)
$list = @()
$current = [System.IO.Path]::GetFullPath($FullPath)
while ($true) {
$list += $current
$parent = [System.IO.Path]::GetDirectoryName($current)
if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) { break }
$current = $parent
}
return $list
}
# --- main ---------------------------------------------------------------
$identity = Get-CurrentIdentity
$meSid = $identity.User.Value
$caller = Get-CallerFacts
Write-Line ('CALLER SID={0} INTEGRITY={1} SANDBOX_MARKERS={2}' -f $meSid, $caller.Integrity, (($caller.Markers | Sort-Object) -join ','))
if ($Fix -and $GrantFullControl) {
Write-Line 'REFUSED -Fix and -GrantFullControl are separate repairs; run one at a time'
exit 2
}
if ($Fix -or $GrantFullControl) {
foreach ($required in @('AllowRoot', 'Out')) {
if (-not (Get-Variable -Name $required -ValueOnly)) {
Write-Line ('REFUSED the requested repair requires -{0}' -f $required)
exit 2
}
}
if (-not (Test-Path -LiteralPath $Out)) { New-Item -ItemType Directory -Path $Out | Out-Null }
}
$fixed = 0
$granted = 0
$refused = 0
foreach ($requested in $Path) {
$full = [System.IO.Path]::GetFullPath($requested)
Write-Line ('PATH={0}' -f $full)
if (-not (Test-Path -LiteralPath $full)) {
Write-Line ' MISSING'
Write-Line 'VERDICT=NOT_THIS_CLASS'
continue
}
$packageTargets = @()
foreach ($ancestor in (Get-Ancestors -FullPath $full)) {
if (-not (Test-Path -LiteralPath $ancestor)) { continue }
$f = Get-ObjectFacts -FullPath $ancestor -Identity $identity -MeSid $meSid
Write-Line (' OBJECT={0} READABLE={1}{2}' -f $f.Object, $f.Readable, $(if ($f.Readable) { '' } else { ' ERROR=' + $f.Error }))
if ($f.Readable) {
Write-Line (' OWNER={0} IS_CURRENT_USER={1} MY_RIGHTS=[{2}] WRITE_DAC={3} WRITE_OWNER={4} LOW_LABEL={5}' -f $f.Owner, $f.OwnerIsMe, $f.MyRights, $f.HasWriteDac, $f.HasWriteOwner, $f.LowLabel)
foreach ($ace in $f.PackageAces) { Write-Line (' PACKAGE_ACE {0}' -f $ace) }
foreach ($sid in ($f.OtherAppContainerSids | Sort-Object -Unique)) { Write-Line (' OTHER_S1_15 SID={0} (reported only)' -f $sid) }
}
if ($f.PackageAces.Count -gt 0) { $packageTargets += $f }
}
# Verdict: a confirmed package ACE is the blocker; otherwise an unmet
# precondition; otherwise this is not the class of failure this skill owns.
# The precondition is judged on the requested path itself. Ancestors above the
# tree DSH grants are not part of that grant, so their ownership and rights would
# otherwise mark every healthy path as a precondition failure.
$targetFacts = Get-ObjectFacts -FullPath $full -Identity $identity -MeSid $meSid
$unreadable = -not $targetFacts.Readable
$needsPrecondition = (-not $unreadable) -and $targetFacts.PackageAces.Count -eq 0 -and
((-not $targetFacts.OwnerIsMe) -or (-not $targetFacts.HasWriteDac) -or (-not $targetFacts.HasWriteOwner))
if ($packageTargets.Count -gt 0) {
$blocked = @($packageTargets | Where-Object { (-not $_.OwnerIsMe) -or (-not $_.HasWriteDac) })
Write-Line ('VERDICT={0}' -f $(if ($blocked.Count -gt 0) { 'BOTH' } else { 'CULPRIT' }))
} elseif ($unreadable) {
Write-Line 'VERDICT=UNREADABLE'
} elseif ($needsPrecondition) {
Write-Line 'VERDICT=PRECONDITION'
} else {
Write-Line 'VERDICT=NOT_THIS_CLASS'
}
if ($GrantFullControl) {
if ($unreadable) { Write-Line 'GRANT_REFUSED the object could not be read; the grant needs an unconfined caller'; $refused++; continue }
if ($targetFacts.PackageAces.Count -gt 0) { Write-Line 'GRANT_REFUSED remove the package SID ACE first with -Fix'; $refused++; continue }
if ($targetFacts.HasWriteDac -and $targetFacts.HasWriteOwner) { Write-Line ('GRANT_SKIPPED {0} already carries WRITE_DAC and WRITE_OWNER' -f $full); continue }
if (-not (Test-UnderRoot -FullPath $full -Root $AllowRoot)) { Write-Line ('GRANT_REFUSED {0} is outside -AllowRoot' -f $full); $refused++; continue }
$danger = Test-DangerousRoot -FullPath $full
if ($danger) { Write-Line ('GRANT_REFUSED {0} is a {1}' -f $full, $danger); $refused++; continue }
$item = Get-Item -LiteralPath $full -Force
if ($item.LinkType) { Write-Line ('GRANT_REFUSED {0} is a reparse point ({1})' -f $full, $item.LinkType); $refused++; continue }
$backup = Join-Path $Out ('acl-backup-{0}.txt' -f ([guid]::NewGuid().ToString('N')))
$before = @(icacls $full)
$before | Set-Content -LiteralPath $backup
Write-Line ('BACKUP {0} -> {1}' -f $full, $backup)
# Address the grant by SID so no account name has to resolve, and leave the
# owner alone: ownership transfer reaches far beyond this fault.
icacls $full /grant "*${meSid}:(F)" | Out-Null
$code = $LASTEXITCODE
$after = Get-ObjectFacts -FullPath $full -Identity $identity -MeSid $meSid
if ($code -eq 0 -and $after.HasWriteDac -and $after.HasWriteOwner) {
Write-Line ('GRANTED {0} SID={1}' -f $full, $meSid); $granted++
Write-Line ('ROLLBACK icacls "{0}" /remove:g "*{1}"' -f $full, $meSid)
} else {
Write-Line ('GRANT_FAILED {0} exit={1}; an unelevated caller cannot grant WRITE_DAC on a directory it does not own, so one elevated run of this command is required' -f $full, $code)
$refused++
}
continue
}
if (-not $Fix) { continue }
if ($unreadable) {
Write-Line 'FIX_REFUSED the object could not be read; repair needs an unconfined caller'
$refused++
continue
}
foreach ($target in $packageTargets) {
if (-not (Test-UnderRoot -FullPath $target.Object -Root $AllowRoot)) {
Write-Line ('FIX_REFUSED {0} is outside -AllowRoot' -f $target.Object); $refused++; continue
}
$danger = Test-DangerousRoot -FullPath $target.Object
if ($danger) { Write-Line ('FIX_REFUSED {0} is a {1}' -f $target.Object, $danger); $refused++; continue }
$item = Get-Item -LiteralPath $target.Object -Force
if ($item.LinkType) { Write-Line ('FIX_REFUSED {0} is a reparse point ({1})' -f $target.Object, $item.LinkType); $refused++; continue }
if (-not ($target.OwnerIsMe -and $target.HasWriteDac)) {
Write-Line ('FIX_REFUSED {0} needs WRITE_DAC first: grant the current user full control (WRITE_DAC and WRITE_OWNER); taking ownership alone is not enough' -f $target.Object)
$refused++
continue
}
$backup = Join-Path $Out ('acl-backup-{0}.txt' -f ([guid]::NewGuid().ToString('N')))
$before = @(icacls $target.Object)
$before | Set-Content -LiteralPath $backup
Write-Line ('BACKUP {0} -> {1}' -f $target.Object, $backup)
foreach ($ace in $target.PackageAces) {
if ($ace -notmatch 'SID=(S-1-15-2-[\d-]+)') { continue }
$sid = $Matches[1]
icacls $target.Object /remove:g "*$sid" | Out-Null
$code = $LASTEXITCODE
$after = Get-ObjectFacts -FullPath $target.Object -Identity $identity -MeSid $meSid
# Prove the removal was surgical. icacls prints the object's first ACE on the
# same line as the path, so strip the path first: otherwise removing an ACE
# that led that line reads as a change to every following line.
$keepBefore = @($before | ForEach-Object {
$line = $_.Trim()
if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() }
$line
} | Where-Object { $_ -notmatch $PACKAGE_SID })
$keepAfter = @($after.AclLines | ForEach-Object {
$line = $_.Trim()
if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() }
$line
} | Where-Object { $_ -notmatch $PACKAGE_SID })
$collateral = (($keepBefore -join "`n") -eq ($keepAfter -join "`n"))
if ($code -eq 0 -and $after.PackageAces.Count -eq 0 -and $collateral) {
Write-Line ('FIXED {0} SID={1}' -f $target.Object, $sid); $fixed++
Write-Line ('ROLLBACK icacls "{0}" /grant "*{1}:(X,RA)"' -f $target.Object, $sid)
} else {
Write-Line ('FIX_FAILED {0} SID={1} exit={2} collateral_change={3}' -f $target.Object, $sid, $code, (-not $collateral))
$refused++
}
}
}
}
if ($Fix -or $GrantFullControl) { Write-Line ('SUMMARY FIXED={0} GRANTED={1} REFUSED={2}' -f $fixed, $granted, $refused) }
@@ -29,22 +29,26 @@
"lib/index.js",
"lib/runner.js",
"lib/types-*.js",
"assets",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-skill": "workspace:*",
"@deepseek-ai/dsh-subprocess": "workspace:*"
},
"dependencies": {
"@deepseek-ai/dsh-lazy-require": "workspace:*",
"@deepseek-ai/dsh-win32-process": "workspace:*",
"koffi": "^3.1.0"
"koffi": "^3.1.0",
"yaml": "^2.4.2"
},
"devDependencies": {
"@deepseek-ai/dsh-pwsh-local": "workspace:*",
"@deepseek-ai/dsh-sandbox-local": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-skill": "workspace:*",
"@deepseek-ai/dsh-subprocess": "workspace:*",
"@deepseek-ai/dsh-subprocess-local": "workspace:*"
}
@@ -0,0 +1,68 @@
/**
* Registers the bundled Windows sandbox ACL diagnosis skill.
*
* The skill ships beside the backend whose failures it explains, so the assets
* resolve relative to this module exactly as `skill-office` resolves its own. The
* command-line side of the ACL backend never imports it: only the always-composed
* local sandbox provider calls {@link registerAclDiagnosisSkill}, and only where
* this backend can be the active one.
*
* @module @deepseek-ai/dsh-sandbox-windows-acl
*/
import { readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import { BUNDLED_SKILL_RANK, type SkillCandidate, type SkillProvider } from '@deepseek-ai/dsh-skill'
import { parse as parseYaml } from 'yaml'
/** Bundled skill that diagnoses Windows sandbox ACL failures. */
export const ACL_DIAGNOSIS_SKILL = 'diagnose-windows-sandbox-acl'
/** Provider name this module registers the skill under. */
const PROVIDER = 'dsh-windows-acl'
function parseSkill(raw: string, path: string): { description: string; content: string } {
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(raw)
if (frontmatter?.[1] === undefined) throw new Error(`dsh-sandbox-windows-acl: ${path} has no YAML frontmatter`)
const metadata: unknown = parseYaml(frontmatter[1])
const description = typeof metadata === 'object' && metadata !== null && 'description' in metadata
? metadata.description
: undefined
if (typeof description !== 'string' || description.length === 0) {
throw new Error(`dsh-sandbox-windows-acl: ${path} has no description`)
}
return { description, content: raw.slice(frontmatter[0].length).trim() }
}
/**
* Register the bundled diagnosis skill on a session's skill registry.
* @param ctx - Context carrying the skill registry.
*/
export function registerAclDiagnosisSkill(ctx: Context): void {
const directory = fileURLToPath(new URL(`../assets/${ACL_DIAGNOSIS_SKILL}/`, import.meta.url))
const locator = join(directory, 'SKILL.md')
const { description } = parseSkill(readFileSync(locator, 'utf8'), locator)
const candidate: SkillCandidate = {
name: ACL_DIAGNOSIS_SKILL,
description,
invocation: { modelInvocable: true, userInvocable: true },
provider: PROVIDER,
source: 'bundled',
rank: BUNDLED_SKILL_RANK,
resourceBase: { kind: 'directory', path: directory },
locator,
}
const provider: SkillProvider = {
name: PROVIDER,
list: () => Promise.resolve([candidate]),
async get(entry, options) {
const { rank: _rank, locator: entryPath, ...summary } = entry
const raw = await readFile(entryPath as string, { encoding: 'utf8', signal: options.signal })
return { ...summary, content: parseSkill(raw, entryPath as string).content }
},
}
ctx.skills.registerProvider(() => provider)
}
@@ -159,6 +159,8 @@ function freeSidBestEffort(
* `manageDacls: false` the caller owns the grants (the sandbox seam's grant
* reuse): init() applies none and dispose() revokes none.
*/
export { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from './acl-skill.ts'
export class AclSandbox {
/** Absolute writable directories (constructor-validated). */
readonly writableDirs: string[]
@@ -0,0 +1,48 @@
/**
* The Windows ACL diagnosis skill ships as assets, so its catalog entry and the
* body the registry hands a model are asserted here rather than through a session.
* The registration is exercised on a real skill registry fiber, which is also what
* proves disposal removes the candidate.
*/
import { Context } from '@deepseek-ai/cordis'
import SkillRegistry from '@deepseek-ai/dsh-skill'
import { describe, expect, it } from 'vitest'
import { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from '../src/acl-skill.ts'
describe('bundled Windows ACL diagnosis skill', () => {
it('registers the packaged body and removes the candidate on disposal', async () => {
const ctx = new Context()
try {
await ctx.plugin(SkillRegistry)
const fiber = await ctx.plugin({
name: 'acl-skill-registration',
inject: ['skills'],
apply: (inner: Context) => { registerAclDiagnosisSkill(inner) },
})
const catalog = await ctx.skills.list()
expect(catalog.map(skill => skill.name)).toEqual([ACL_DIAGNOSIS_SKILL])
const entry = catalog[0]!
expect(entry.description.length).toBeGreaterThan(0)
expect(entry.description.length).toBeLessThanOrEqual(500)
expect(entry).toMatchObject({
source: 'bundled',
provider: 'dsh-windows-acl',
invocation: { modelInvocable: true, userInvocable: true },
})
const loaded = await ctx.skills.get(ACL_DIAGNOSIS_SKILL)
expect(loaded?.resourceBase).toMatchObject({ kind: 'directory' })
// The body names the bundled script — the repair path the model must use
// instead of editing ACLs by hand.
expect(loaded?.content).toContain('scripts/diagnose-windows-sandbox-acl.ps1')
expect(loaded?.content).toContain('Step 4')
await fiber.dispose()
expect(await ctx.skills.list()).toEqual([])
} finally {
await ctx.fiber.dispose()
}
})
})
@@ -0,0 +1,199 @@
/**
* The diagnosis script shipped with the `diagnose-windows-sandbox-acl` skill is a
* runnable artifact, so its classification and its two repairs are tested directly
* rather than through a model.
*
* Every case builds its own scratch directory under the system temp directory and
* never touches the user profile. The foreign package SID is synthetic: the access
* check that blocks a below-Medium caller keys on the SID class, not on a profile
* that exists, so a made-up `S-1-15-2-*` value exercises the same path. ACEs are
* written with `icacls` and the `*SID` spelling, which no account name has to
* resolve.
*
* Cleanup restores full control before deleting: the grant case leaves the caller
* with `WRITE_DAC` but not `WRITE_OWNER`, and a directory that stripped its own
* `WRITE_OWNER` cannot be removed by the same unelevated caller.
*/
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
const isWin32 = process.platform === 'win32'
function pwshAvailable(): boolean {
try {
execFileSync('where.exe', ['pwsh'], { stdio: 'ignore' })
return true
} catch {
return false
}
}
const script = fileURLToPath(new URL('../assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1', import.meta.url))
const PACKAGE_SID = 'S-1-15-2-1-2-3-4'
const CAPABILITY_SID = 'S-1-15-3-1-2-3-4'
interface ScriptRun {
readonly code: number
readonly output: string
}
function pwsh(command: string): string {
return execFileSync('pwsh', ['/NoLogo', '/NonInteractive', '/NoProfile', '-Command', command], { encoding: 'utf8' })
}
function runScript(args: readonly string[]): ScriptRun {
try {
const stdout = execFileSync('pwsh', ['/NoLogo', '/NonInteractive', '/NoProfile', '-File', script, ...args], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
return { code: 0, output: stdout }
} catch (error) {
const failure = error as { status?: number | null; stdout?: string; stderr?: string }
return { code: failure.status ?? -1, output: `${failure.stdout ?? ''}${failure.stderr ?? ''}` }
}
}
function icacls(path: string, ...args: readonly string[]): string {
return pwsh(`(icacls '${path}' ${args.map(argument => `"${argument}"`).join(' ')}) -join "\`n"`)
}
function aclLines(path: string): string[] {
return icacls(path).split(/\r?\n/u).map(line => line.trim()).filter(line => line !== '')
}
function ownerOf(path: string): string {
return pwsh(`(Get-Acl -LiteralPath '${path}').GetOwner([System.Security.Principal.SecurityIdentifier]).Value`).trim()
}
function normalized(path: string, lines: readonly string[]): string[] {
return lines.map((line) => {
const trimmed = line.trim()
return trimmed.startsWith(path) ? trimmed.slice(path.length).trim() : trimmed
})
}
describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl script', () => {
let scratch!: string
let outDir!: string
let meSid!: string
function makeDir(name: string): string {
const dir = join(scratch, name)
mkdirSync(dir, { recursive: true })
return dir
}
function stamp(path: string, sid: string, type: 'grant' | 'deny' = 'grant'): void {
icacls(path, `/${type}`, `*${sid}:(RX)`)
}
beforeAll(() => {
scratch = mkdtempSync(join(tmpdir(), 'dsh-acl-diagnose-'))
outDir = makeDir('out')
meSid = pwsh('[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value').trim()
})
afterAll(() => {
// Restore what the grant case withheld before deleting the scratch tree.
pwsh(
`Get-ChildItem -LiteralPath '${scratch}' -Recurse -Force -Directory -ErrorAction SilentlyContinue | ` +
`ForEach-Object { icacls $_.FullName /grant:r "*${meSid}:(F)" | Out-Null }; ` +
`icacls '${scratch}' /grant:r "*${meSid}:(F)" | Out-Null`,
)
rmSync(scratch, { recursive: true, force: true })
})
it('names a foreign package-SID ACE as the blocker and removes only that ACE', () => {
const target = makeDir('stamped')
stamp(target, PACKAGE_SID)
const before = normalized(target, aclLines(target))
expect(before.join('\n')).toContain(PACKAGE_SID)
const diagnosis = runScript(['-Path', target])
expect(diagnosis.output, diagnosis.output).toContain('VERDICT=CULPRIT')
expect(diagnosis.output, diagnosis.output).toContain(`PACKAGE_ACE SID=${PACKAGE_SID}`)
const repair = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix'])
expect(repair.output, repair.output).toContain(`FIXED ${target} SID=${PACKAGE_SID}`)
expect(repair.output, repair.output).toContain('SUMMARY FIXED=1 GRANTED=0 REFUSED=0')
// Exactly the foreign ACE disappears; every other line survives unchanged.
expect(normalized(target, aclLines(target))).toEqual(before.filter(line => !line.includes(PACKAGE_SID)))
}, 60_000)
it('reports a healthy directory as not this class and changes nothing', () => {
const target = makeDir('healthy')
const before = normalized(target, aclLines(target))
const diagnosis = runScript(['-Path', target])
expect(diagnosis.output, diagnosis.output).toContain('VERDICT=NOT_THIS_CLASS')
expect(diagnosis.output, diagnosis.output).not.toContain('PACKAGE_ACE')
const repair = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix'])
expect(repair.output, repair.output).toContain('SUMMARY FIXED=0 GRANTED=0 REFUSED=0')
expect(normalized(target, aclLines(target))).toEqual(before)
}, 60_000)
it('reports an inert capability SID and an inert DENY ACE without touching either', () => {
const capability = makeDir('capability')
stamp(capability, CAPABILITY_SID)
const deny = makeDir('denied-package')
stamp(deny, PACKAGE_SID, 'deny')
const before = new Map([
[capability, normalized(capability, aclLines(capability))],
[deny, normalized(deny, aclLines(deny))],
])
const capabilityDiagnosis = runScript(['-Path', capability])
expect(capabilityDiagnosis.output, capabilityDiagnosis.output).toContain(`OTHER_S1_15 SID=${CAPABILITY_SID}`)
expect(capabilityDiagnosis.output, capabilityDiagnosis.output).not.toContain('PACKAGE_ACE')
expect(capabilityDiagnosis.output, capabilityDiagnosis.output).not.toContain('VERDICT=CULPRIT')
const denyDiagnosis = runScript(['-Path', deny])
expect(denyDiagnosis.output, denyDiagnosis.output).not.toContain('PACKAGE_ACE')
expect(denyDiagnosis.output, denyDiagnosis.output).not.toContain('VERDICT=CULPRIT')
// `pwsh -File` passes `-Path a,b` literally, so each object is diagnosed on its own.
for (const path of [capability, deny]) {
const repair = runScript(['-Path', path, '-AllowRoot', scratch, '-Out', outDir, '-Fix'])
expect(repair.output, repair.output).toContain('SUMMARY FIXED=0 GRANTED=0 REFUSED=0')
expect(normalized(path, aclLines(path))).toEqual(before.get(path))
}
}, 90_000)
it('grants full control for a Modify-only DACL without changing the owner', () => {
const target = makeDir('missing-write-owner')
pwsh(`icacls '${target}' /inheritance:r /grant:r "*${meSid}:(M)" | Out-Null`)
const ownerBefore = ownerOf(target)
const diagnosis = runScript(['-Path', target])
expect(diagnosis.output, diagnosis.output).toContain('VERDICT=PRECONDITION')
const repair = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-GrantFullControl'])
expect(repair.output, repair.output).toContain('SUMMARY FIXED=0 GRANTED=1 REFUSED=0')
expect(aclLines(target).join('\n')).toMatch(/\(F\)/u)
expect(ownerOf(target)).toBe(ownerBefore)
}, 60_000)
it('refuses a target outside -AllowRoot and refuses contradictory switches', () => {
const target = makeDir('outside-root')
const unrelated = makeDir('unrelated-root')
stamp(target, PACKAGE_SID)
const refused = runScript(['-Path', target, '-AllowRoot', unrelated, '-Out', outDir, '-Fix'])
expect(refused.output, refused.output).toContain('FIX_REFUSED')
expect(refused.output, refused.output).toContain('is outside -AllowRoot')
expect(refused.output, refused.output).toContain('SUMMARY FIXED=0 GRANTED=0 REFUSED=1')
expect(aclLines(target).join('\n')).toContain(PACKAGE_SID)
const contradictory = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix', '-GrantFullControl'])
expect(contradictory.code).toBe(2)
expect(contradictory.output, contradictory.output).toContain('run one at a time')
}, 60_000)
})
@@ -25,6 +25,9 @@
},
{
"path": "../../util/lazy-require"
},
{
"path": "../../skill/skill"
}
]
}
+6
View File
@@ -10186,6 +10186,9 @@ importers:
koffi:
specifier: ^3.1.0
version: 3.1.1
yaml:
specifier: ^2.4.2
version: 2.9.0
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:~
@@ -10196,6 +10199,9 @@ importers:
'@deepseek-ai/dsh-sandbox-local':
specifier: workspace:*
version: link:../sandbox-local
'@deepseek-ai/dsh-skill':
specifier: workspace:*
version: link:../../skill/skill
'@deepseek-ai/dsh-subprocess':
specifier: workspace:*
version: link:../../subprocess/subprocess
+1 -1
View File
@@ -207,7 +207,7 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
// The argv-prefix runner entry ships beside the lib as its own bundle;
// sandbox-local resolves it through the package's ./runner export. tsdown
// also shares its generated FFI code through a hashed runtime chunk.
'@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'],
'@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js', 'assets'],
'@deepseek-ai/dsh-skill-badge': ['assets'],
'@deepseek-ai/dsh-skill-office': ['assets'],
'@deepseek-ai/dsh-subprocess': ['lib/control.js'],