@deepseek-ai/dsh-sandbox-windows-acl 0.1.7-rc.2 → 0.2.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -25,10 +25,10 @@
25
25
  zh: 30bc370a5281bf0d
26
26
  /deepseek-ai-dsh-sandbox-windows-acl/use-this-package/what-confinement-gives-you:
27
27
  en: 289e65bddf20dd79
28
- zh: 44b9fcedf944546a
28
+ zh: 7ff72ed64c4b4d1c
29
29
  /deepseek-ai-dsh-sandbox-windows-acl/use-this-package/failures-and-recovery:
30
- en: f93372e784cda63e
31
- zh: f8371114f3481550
30
+ en: 7fc0707debb1e67c
31
+ zh: e3b5529bf82f868e
32
32
  /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation:
33
33
  en: c2cde86c85c2c619
34
34
  zh: a29a114e591f9d3f
@@ -51,14 +51,14 @@
51
51
  en: 7850da9522bf984b
52
52
  zh: a9f2273a85bafcf9
53
53
  /deepseek-ai-dsh-sandbox-windows-acl/model-experience:
54
- en: c94416e26fec28cd
55
- zh: a16f435e8afbdf30
54
+ en: bbb19594a2c2e4d4
55
+ zh: 075a65d9b9182aad
56
56
  /deepseek-ai-dsh-sandbox-windows-acl/model-experience/kv-cache-effect:
57
- en: 796af37d6f6add68
58
- zh: 1e0dc5a58e5456be
57
+ en: 817eb1b7de13872e
58
+ zh: 06fe175e41e7459b
59
59
  /deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work:
60
- en: 2b6b967c62766d47
61
- zh: 0faf9ec390504967
60
+ en: 046831a864786b2a
61
+ zh: 4a261fbab3396ea4
62
62
  /deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work/dev-note:
63
63
  en: cc273320f6a1beaf
64
64
  zh: 5c57a312ddc50651
package/README.md CHANGED
@@ -75,6 +75,14 @@ Temp isolation is per live session/workspace pair: sessions sharing a workspace
75
75
 
76
76
  `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`.
77
77
 
78
+ A denial this backend cannot explain is diagnosed by the `diagnose-windows-sandbox-acl` skill bundled in `assets/`. `registerAclDiagnosisSkill` contributes it when `dsh-sandbox-local` uses its built-in Windows runner and a skill registry is available. Registration extracts resources for external PowerShell, including in ASAR and SEA deployments; fiber disposal unregisters the provider and removes its copy. Missing assets fail registration.
79
+
80
+ One run diagnoses and repairs. The script has no modes: it reads the requested path and every ancestor, adds the signed-in user's full-control allow ACE to chain directories that lack effective `WRITE_DAC` or `WRITE_OWNER`, and removes explicit package allow ACEs at their sources, ancestor first, verifying each change by re-reading it. Repairs preserve denies, owner, inheritance and SACL, and back up each changed DACL. An object is modified only when it is `-AllowRoot` or strictly inside it, so a workspace root can repair itself; reparse paths and managed application trees are refused, and a repair needs effective `WRITE_DAC`. Failed repairs restore attempted changes in reverse order and exit nonzero. Follow the [bundled skill](assets/diagnose-windows-sandbox-acl/SKILL.md) for the single command, original-operation verification and recovery.
81
+
82
+ Every run prints `REPORT` JSON records of observations, actions, reasons and verification, then a final `RECAP` line carrying the verdicts, changes, verifications, refusals and scans, and a summary with the counts, recovery commands and `nextAction`; unknown observations remain unknown. The same run writes every record to `-Out\acl-report-*.jsonl`, because tool output keeps only its tail. `-Out` also receives two recovery files per change: the DACL the object owned before that change and the independent script that restores it. Action completion records API execution; verification records the observed result. The [recovery decisions](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md#acl-diagnosis-and-recovery) explain resource ownership and rollback limits.
83
+
84
+ Inspection visits the requested object and each ancestor through the filesystem root. Each ACL observation's `path` identifies the inspected object; the classification's `details.packageObjects` lists objects carrying package allow ACEs without establishing the cause of the original failure. Each package removal is verified per object by comparing the recorded own ACEs with the re-read DACL, so a deny that shares the removed allow's SID remains visible in the verification details.
85
+
78
86
  -----
79
87
 
80
88
  <a id="understand-the-implementation"></a>
@@ -156,14 +164,16 @@ Start with the subsystem reference for the shared vocabulary, then the provider
156
164
  <a id="model-experience"></a>
157
165
  ## Model Experience
158
166
 
159
- 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.
167
+ 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.
160
168
 
161
169
  #### KV Cache effect
162
170
 
163
- None directly; the denial surface belongs to the tool layer.
171
+ One extra catalog entry on Windows: the skill's description enters context with the catalog, and the body enters only when invoked. Resource extraction happens at registration and does not add script contents to model context. The denial surface itself stays in the tool layer.
164
172
 
165
173
  ## Known Limitations and Deferred Work
166
174
 
175
+ - **Diagnosis preserves integrity labels** — it cannot fix Low-labeled executables affecting programs launched outside DSH. It also cannot repair a caller's missing `WRITE_DAC`; extracted and recovery scripts are user-writable and are not supported elevated entrypoints. Resource directories can remain after an unclean shutdown.
176
+
167
177
  <a id="known-limitations-and-deferred-work"></a>
168
178
 
169
179
 
@@ -178,6 +188,7 @@ These limits define when the backend is a poor fit or needs special operational
178
188
  - **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.
179
189
  - **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.
180
190
  - **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.
191
+ - **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 path stays undiagnosed.
181
192
  - **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.
182
193
 
183
194
  <a id="dev-note"></a>
package/README.zh.md CHANGED
@@ -71,10 +71,20 @@ rmSync(tempDir, { recursive: true, force: true })
71
71
 
72
72
  临时隔离按每个活跃的会话/工作区对进行:共享工作区的会话共享其写权限,但无法写入彼此的临时目录。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。
73
73
 
74
+ <a id="failures-and-recovery"></a>
75
+
74
76
  ### 失败与恢复
75
77
 
76
78
  `init()` 在任何 Win32 失败时抛出——子进程绝不会不受限制地 spawn。执行命令前失败的 runner 会向 stderr 打印 `windows-acl-run: <detail>` 并以 127 退出,seam 的 runner 失败规则将其归类为损坏的沙箱,而非拒绝。清理按设计尽力而为:`dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`。
77
79
 
80
+ 此后端无法解释的拒绝交给 `assets/` 中随包发布的 `diagnose-windows-sandbox-acl` 技能诊断。当 `dsh-sandbox-local` 使用内置 Windows runner 且技能注册表可用时,`registerAclDiagnosisSkill` 会注册它。注册时提取供外部 PowerShell 使用的资源,ASAR 和 SEA 部署也适用;fiber 释放时注销提供者并删除副本。缺少资源会导致注册失败。
81
+
82
+ 一次运行同时完成诊断与修复。脚本没有模式开关:它读取请求路径及每一级祖先,为链路上缺少有效 `WRITE_DAC` 或 `WRITE_OWNER` 的目录补上当前用户的完全控制允许 ACE,并从祖先开始在其来源移除显式包允许 ACE,每处改动都通过重新读取来验证。修复保留拒绝条目、所有者、继承和 SACL,并备份每个被修改的 DACL。仅当对象就是 `-AllowRoot` 或严格位于其内部时才会被修改,因此工作区根目录可以自我修复;重解析路径和受管理的应用目录会被拒绝,修复需要有效的 `WRITE_DAC`。修复失败时按相反顺序恢复尝试修改的内容,并以非零状态退出。单条命令、原始操作验证及恢复流程见[内置技能](assets/diagnose-windows-sandbox-acl/SKILL.md)。
83
+
84
+ 每次执行都会打印包含观察、操作、原因和验证结果的 `REPORT` JSON 记录,最后给出一行 `RECAP`(判定、改动、验证、拒绝与扫描结果)以及含计数、恢复命令和 `nextAction` 的摘要;未知观察保持未知。同一次执行还会把全部记录写入 `-Out\acl-report-*.jsonl`,因为工具输出只保留末尾部分。`-Out` 同时为每次改动写入两个恢复文件:该对象在本次改动前拥有的 DACL,以及用于恢复它的独立脚本。操作完成记录 API 执行情况,验证记录实际观察到的结果。[恢复决策](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md#acl-diagnosis-and-recovery) 说明资源归属和回滚限制。
85
+
86
+ 检查会从请求对象逐级访问祖先,直到文件系统根目录。每条 ACL 观察的 `path` 标识实际检查的对象;分类结果的 `details.packageObjects` 列出包含包允许 ACE 的对象,但不能据此确认原始失败的原因。每次包 ACE 移除都按对象验证:把记录下来的自身 ACE 与重新读取的 DACL 比对,因此与被移除允许项共用 SID 的拒绝条目仍会出现在验证详情中。
87
+
78
88
  -----
79
89
 
80
90
  <a id="understand-the-implementation"></a>
@@ -156,14 +166,16 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服
156
166
  <a id="model-experience"></a>
157
167
  ## 模型体验
158
168
 
159
- 间接地通过 [`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 选择。
169
+ 间接地通过 [`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` 技能,模型正是从它学会诊断工具层只能上报的拒绝。
160
170
 
161
171
  #### KV Cache 影响
162
172
 
163
- 无直接影响;拒绝面属于工具层。
173
+ Windows 上多一个目录条目:技能描述随目录进入上下文,正文只在调用时进入。资源在注册时提取,不会把脚本内容加入模型上下文。拒绝面本身仍属于工具层。
164
174
 
165
175
  ## 已知限制与延期工作
166
176
 
177
+ - **诊断保留完整性标签**——它无法修复 Low 标签的可执行文件影响用户在 DSH 外启动程序的问题,也无法修复调用者缺少 `WRITE_DAC` 的情况;提取的脚本和恢复脚本均可由用户写入,不支持作为提权入口。非正常退出后可能残留资源目录。
178
+
167
179
  <a id="known-limitations-and-deferred-work"></a>
168
180
 
169
181
 
@@ -178,6 +190,7 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服
178
190
  - **读侧隔离与网络策略不在范围内**——`WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。
179
191
  - **读取会被其他基于 AppContainer 的工具以包 SID 授权过的对象挡住。** 在本机上,当文件的 DACL 携带针对包 SID(`S-1-15-2-…`)的 ACE 时,Low 完整性的令牌无法访问它——即使同一份 DACL 同时向用户授予完全控制、向 Everyone 授予读取(已观测:只给新文件加这一条 ACE 即可复现拒绝,补授 Everyone 读取无法解除,而同样内容复制到别处仍可读)。其背后的内核规则尚未确证,也不由本包掌控;以 AppContainer 自我隔离的工具正是会写入这类 ACE,因此被它们标记过的目录树对本后端的子进程将不可读。移除外来 ACE(或重新安装受影响的目录树)即可恢复访问。
180
192
  - **宽目录与 FAT 卷警告已推迟;FAT 类残留未经验证。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标不存储安全描述符;其有效完整性标签由系统分配而非记录在对象上,因此标签层在该处的行为未经测试。FAT 仍被视为遗留残留。
193
+ - **随包发布的诊断技能需要不受限的调用者。** 无论分类还是修复,都要读写受限子进程够不到的安全描述符,因此会话必须为那一次调用升权;没有审批通道时升权 fail-closed,该路径保持未诊断。
181
194
  - **PowerShell 语言模式因受限模式而异。** 在 `read-only` 下,PowerShell 无法在临时目录中创建 AppLocker 探针文件,因此会保守地以 ConstrainedLanguage 启动(`Add-Type`、非核心 .NET 静态调用、COM 与反射失败);交付的 `workspace-write` 路径可让探针完成,因此除非主机范围的 WDAC/AppLocker 策略另有规定,否则 pwsh 保持 FullLanguage,而直接使用 `AclSandbox` 并配置 `tempDir: null` 时则没有这一保证。这一区别属于 PowerShell 启动行为,不是 ACL 写入边界的一部分。
182
195
 
183
196
  <a id="dev-note"></a>
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: diagnose-windows-sandbox-acl
3
+ description: 'Use on Windows for unexpected DSH sandbox access denials: workspace writes or listing fail, or an ordinarily readable path cannot be read. One bundled command inspects the path and every ancestor and repairs the ACL problems it proves in that same run. Expected confinement denials need no ACL repair.'
4
+ ---
5
+
6
+ # Diagnose Windows sandbox ACL failures
7
+
8
+ **Write every approval request in plain words, in the user's language.** The prompt is all the user reads before widening access, so it must stand alone: which folder the script touches, that it adds the signed-in user's full-control entry where a right is missing and removes foreign package entries, that file contents and owners are unchanged, and that the printed recovery command undoes each change. Keep error codes, `WRITE_DAC`/`WRITE_OWNER`, `S-1-15-2-*` SIDs, `icacls`, ACL, ACE, verdict names and switches out of the request: write Windows file permissions, not ACL or ACE. Ask once, for one command.
9
+
10
+ ## One command diagnoses and repairs
11
+
12
+ The script has no modes. `-Path`, `-AllowRoot` and `-Out` read the path and every ancestor and repair what the observations prove, in the same run:
13
+
14
+ - directories on that chain that lack effective `WRITE_DAC` or `WRITE_OWNER` receive a full-control allow ACE for the signed-in user, because DSH cannot provision its workspace grant without them;
15
+ - explicit AppContainer package allow ACEs (`S-1-15-2-*`, except the well-known groups ending in 1 or 2) are removed at their sources, ancestor first, which also removes those packages' access;
16
+ - a directory the sandbox cannot provision — the state its provisioning error reports on the workspace root — or the authorized root itself also has its subtree searched for those ACEs in the same run, so a deeper entry needs no second request. That bounded walk reports `truncated` and unreadable directories; if truncated, pass the still-failing deeper path once more.
17
+
18
+ Every change is backed up, then verified by re-reading it. `-AllowRoot` bounds all of it: an object is changed only when it is that directory or strictly inside it, so a workspace root can repair itself. Never split this into a diagnostic call and a repair call, never ask twice for one repair, and never pass a mode switch that does not exist.
19
+
20
+ ```powershell
21
+ & '<skill-directory>\scripts\diagnose-windows-sandbox-acl.ps1' -Path '<failing-path>' -AllowRoot '<authorized-directory>' -Out '<recovery-directory>'
22
+ exit $LASTEXITCODE
23
+ ```
24
+
25
+ Substitute full, quoted paths. Keep `-Out` persistent and user-owned, preferably beside the failing workspace; never use the skill resource directory, which is deleted when the skill unloads. Pass one path per invocation.
26
+
27
+ ## Run it
28
+
29
+ Repeat the failing operation once confined. The script writes permissions, which the confined token cannot do — run it there and it would report the sandbox's own restriction as a missing right — so request approval for that one command and run it unconfined. A confined run of the script is never useful. Unconfined does not elevate the Windows token.
30
+
31
+ Diagnose unexpected denials of workspace writes, listing, or plainly readable paths; explain expected ones instead: writes outside the workspace, any write in `read-only`, piped grandchild `spawn EPERM`, ConstrainedLanguage errors. If approval is refused or unavailable, report the path as undiagnosed and stop.
32
+
33
+ ## Read the output
34
+
35
+ Every record reaches stdout, the `acl-report-*.jsonl` file under `-Out`, and the final `RECAP` line, which carries the verdicts, changes, verifications, refusals and scans. Tool output keeps only its tail, so read the recap first, and read specific records from the report when it is not enough. Trust `verification` records, never `completed` actions. Decide from `details.nextAction`:
36
+
37
+ | `nextAction` | Meaning |
38
+ |---|---|
39
+ | `verify_original_confined_operation` | Repairs verified; repeat the original operation confined. |
40
+ | `stop` | Nothing repaired, or a refusal ended the run. Report and stop. |
41
+ | `restore_pending_then_stop` | Rollback unverified. Run the printed recovery commands in order, then stop. |
42
+
43
+ A deny ACE's presence alone does not establish causation, and the script never removes one; DSH's `S-1-4-*` grants and the Everyone `DeleteSubdirectoriesAndFiles` deny are expected, not conflicts. A deny that blocks the repair ends the run without a repair (`REPAIR_REFUSED`, or `GRANT_FAILED`) after restoring what it attempted. Each change leaves two files in `-Out` (`acl-backup-<id>.json` and its `.ps1`), and the run prints the matching `ROLLBACK` command.
44
+
45
+ ## After a repair, and when it stops
46
+
47
+ Repeat the original failed operation confined. A verified repair is not undone because the original operation fails for a further reason: continue from the new observations, and if a deeper path is still denied, run the same one command there — again one call, one approval.
48
+
49
+ **Stop after any failed or refused repair, or failed verification.** The script already restored that invocation's changes; do not repeat it or start another repair, and never remove a deny ACE by hand.
50
+
51
+ **A stopped run still owes the user a decision.** Name the blocking object and the right or ACE it lacks, what changed and what was rolled back, the recovery commands in order, and the report path. Only the user can lift confinement, so ask them to switch this session to **full access** (Chinese UI: 完全权限) temporarily, and say what that opens: keep working under it, or ask you to keep investigating this file-permission problem, since the report already records the mechanism and the evidence. Then ask them to send this session as feedback, quoting the report records that matter, so the unhandled scenario reaches us — only what reaches this conversation travels with it. For example:
52
+
53
+ > 这个工作区被 Windows 文件权限挡住了:`<对象>` 上 `<缺哪个权限 / 哪条权限项>`。你可以把本会话切到「完全权限」(full access) 临时继续工作;切完之后既能直接干活,也可以让我继续排查这个文件权限问题(报告里已经有机制和证据)。也麻烦把这个会话作为反馈发出去,好让我们补上这个场景。
54
+ >
55
+ > This workspace is blocked by Windows file permissions: `<object>` `<missing right / offending entry>`. Switch this session to full access (完全权限) to keep working meanwhile; then either continue your work or have me keep investigating this file-permission problem from the report. Please also send this session as feedback so we can cover the case.
56
+
57
+ ## Never
58
+
59
+ - edit ACLs by hand, change an owner, erase a deny, or replace child permissions recursively;
60
+ - run the script elevated, through UAC or `runas`;
61
+ - widen `-AllowRoot` to reach an ancestor, or repeat a denied or failed call.
62
+
63
+ Report in the user's language: analyzed paths, changes, verification, recovery commands, next step. Label an authorized unconfined run as such, not as a sandbox repair.
@@ -0,0 +1,798 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Diagnose the DSH sandbox ACL failures on Windows and repair them in one run.
4
+
5
+ .DESCRIPTION
6
+ A denial inside the DSH sandbox is worth diagnosing only when it contradicts
7
+ what the active mode promises: writes inside the workspace, or reads that the
8
+ signed-in user should plainly have. One invocation reads each requested path
9
+ and every ancestor, then repairs what it can in the same run:
10
+
11
+ * every directory on that chain inside -AllowRoot that lacks effective
12
+ WRITE_DAC or WRITE_OWNER gets a FullControl allow ACE for the current
13
+ user, so the sandbox can provision the grant it needs; and
14
+ * every explicit AppContainer package allow ACE (S-1-15-2-*, excluding the
15
+ well-known groups ending in 1 or 2) is removed at its source, ancestor
16
+ first, which also removes that package's access to the tree.
17
+
18
+ A directory the caller cannot provision is the state the sandbox reports on the
19
+ workspace root, while the conflicting entry often sits deeper; the authorized root
20
+ is examined for the same reason. For either case the same run also collects explicit
21
+ package allow ACEs under the requested directory, so they do not need a second call.
22
+ The walk is bounded, enters no reparse point or managed application tree, and reports
23
+ truncation and unreadable directories instead of claiming a complete walk.
24
+
25
+ Every observation, change, verification and recovery command is printed as it
26
+ happens, so one run shows the caller the complete set of actions.
27
+
28
+ -AllowRoot bounds every change: an object is modified only when it is that
29
+ directory or strictly inside it, so a caller can repair its own workspace
30
+ root. Reparse paths and managed application trees are refused. Each modified
31
+ object is backed up first, with an independent recovery script, and every DACL
32
+ write is verified by re-reading it. A failed repair restores all attempted
33
+ DACL changes from this invocation in reverse order and stops.
34
+
35
+ The script never creates, deletes, or writes the contents of any file. Its
36
+ only writes are the recovery artifacts under -Out and the DACLs it repairs.
37
+
38
+ .PARAMETER Path
39
+ One or more failing paths. Each is diagnosed and repaired together with its ancestors.
40
+
41
+ .PARAMETER AllowRoot
42
+ Required. Every modified object must be this directory or strictly inside it.
43
+
44
+ .PARAMETER Out
45
+ Required for a repair. Receives one recovery record and one recovery script
46
+ per change, so an object repaired twice keeps both recovery points.
47
+
48
+ .PARAMETER Restore
49
+ Restore the DACL recovery record for exactly one -Path instead of repairing.
50
+ Requires -AllowRoot and WRITE_DAC, preserves owner and SACL.
51
+
52
+ .EXAMPLE
53
+ pwsh -File diagnose-windows-sandbox-acl.ps1 -Path '<failing path>' -AllowRoot '<authorized directory>' -Out '<recovery directory>'
54
+
55
+ .EXAMPLE
56
+ pwsh -File diagnose-windows-sandbox-acl.ps1 -Path '<failing path>' -AllowRoot '<authorized directory>' -Restore '<recovery record>'
57
+ #>
58
+ [CmdletBinding()]
59
+ param(
60
+ [Parameter(Mandatory = $true)][string[]]$Path,
61
+ [string]$AllowRoot,
62
+ [string]$Out,
63
+ [string]$Restore
64
+ )
65
+
66
+ Set-StrictMode -Version Latest
67
+ $ErrorActionPreference = 'Stop'
68
+
69
+ # These two well-known groups name all packages, not one conflicting package.
70
+ $PACKAGE_SID = '^S-1-15-2-(?![12]$)'
71
+ $LOW_LABEL_SID = 'S-1-16-4096'
72
+ # Upper bound on the directories one subtree scan may visit, so a huge workspace
73
+ # cannot turn one approved repair into an unbounded walk. Truncation is reported.
74
+ $SCAN_LIMIT = 2000
75
+
76
+ function Write-Line { param([string]$Text) Write-Output $Text }
77
+
78
+ # Reports bypass the success pipeline so diagnostics cannot become a function's
79
+ # return value. Each JSON record occupies one stdout line, including paths/errors.
80
+ function Write-Report {
81
+ param([string]$Kind, [string]$Operation, [string]$Target, [string]$Status, [string]$Reason, $Details = @{})
82
+ $record = [ordered]@{ kind = $Kind; operation = $Operation; path = $Target; status = $Status; reason = $Reason; details = $Details }
83
+ if ($Kind -eq 'observation' -and $Status -in @('unknown', 'unreadable', 'partial')) { $script:observationFailures++ }
84
+ $script:reports.Add($record)
85
+ $json = $record | ConvertTo-Json -Depth 12 -Compress
86
+ # The complete record set also lands in the report file: tool output is truncated to its
87
+ # tail, so a long run's early records survive only there.
88
+ if ($null -ne $script:reportWriter) {
89
+ try { $script:reportWriter.WriteLine($json); $script:reportWriter.Flush() }
90
+ catch [System.IO.IOException] {
91
+ # stdout keeps only its tail, so this file is the complete record: a write failure
92
+ # has to reach the caller, on stderr, instead of leaving a silently incomplete file.
93
+ [Console]::Error.WriteLine('REPORT_IO_FAILED {0}: {1}' -f $script:reportPath, $_.Exception.Message)
94
+ try { $script:reportWriter.Dispose() } catch { }
95
+ $script:reportWriter = $null
96
+ }
97
+ }
98
+ [Console]::Out.WriteLine('REPORT ' + $json)
99
+ }
100
+ function Invoke-ReportedOperation {
101
+ param([string]$Operation, [string]$Target, [string]$Reason, [string]$Effect, [scriptblock]$Action)
102
+ $entry = [ordered]@{ id = $script:operations.Count + 1; operation = $Operation; path = $Target; effect = $Effect; status = 'started' }
103
+ $script:operations.Add($entry)
104
+ if ($Effect -eq 'acl' -and $Operation -ne 'restore_dacl' -and $script:recoveries.Count -gt 0) {
105
+ $script:recoveries[-1].attempted = $true
106
+ }
107
+ Write-Report action $Operation $Target started $Reason @{ id = $entry.id; effect = $Effect }
108
+ try {
109
+ $result = & $Action
110
+ $entry.status = 'completed'
111
+ Write-Report action $Operation $Target completed $Reason @{ id = $entry.id; effect = $Effect }
112
+ return $result
113
+ } catch {
114
+ $entry.status = 'failed'
115
+ Write-Report action $Operation $Target failed $Reason @{
116
+ id = $entry.id; effect = $Effect; error = (Get-HResultChain $_.Exception)
117
+ state = $(if ($Effect -eq 'none') { 'No mutation requested by this operation.' } else { 'The operation may have partially changed state; completion is unconfirmed.' })
118
+ }
119
+ throw
120
+ }
121
+ }
122
+
123
+ function Write-Decision {
124
+ param([string]$Operation, [string]$Target, [string]$Status, [string]$Reason)
125
+ Write-Report decision $Operation $Target $Status $Reason
126
+ }
127
+
128
+ # Unwrap nested exceptions: PowerShell wraps Win32 failures, and only the inner
129
+ # exception carries the real HRESULT the caller needs to classify the failure.
130
+ function Get-HResultChain {
131
+ param([System.Exception]$Exception)
132
+ $chain = @()
133
+ $e = $Exception
134
+ while ($null -ne $e) {
135
+ $chain += ('{0}=0x{1:X8}/win32={2}: {3}' -f $e.GetType().Name, $e.HResult, ($e.HResult -band 0xFFFF), $e.Message)
136
+ $e = $e.InnerException
137
+ }
138
+ return ($chain -join ' <- ')
139
+ }
140
+
141
+ function Get-CurrentIdentity {
142
+ return [System.Security.Principal.WindowsIdentity]::GetCurrent()
143
+ }
144
+
145
+ # Integrity level affects the symptom: the same foreign
146
+ # ACE fails a grant when the caller is below Medium and merely blocks the child
147
+ # when the caller is not.
148
+ # The integrity level lives in the token's group list, which .NET filters out of
149
+ # WindowsIdentity.Groups, so read it through GetTokenInformation. Add-Type compiles
150
+ # in-process on PowerShell 7. Never spawn whoami.exe for this: inside a below-Medium
151
+ # token that process fails to initialize (STATUS_DLL_INIT_FAILED, 0xc0000142) and
152
+ # raises an application-error dialog on the user's desktop.
153
+ function Initialize-NativeApi {
154
+ if (-not ('Dsh.TokenInfo' -as [type])) {
155
+ Add-Type -Namespace Dsh -Name TokenInfo -MemberDefinition @'
156
+ [StructLayout(LayoutKind.Sequential)]
157
+ public struct SID_AND_ATTRIBUTES { public IntPtr Sid; public uint Attributes; }
158
+ [StructLayout(LayoutKind.Sequential)]
159
+ public struct TOKEN_MANDATORY_LABEL { public SID_AND_ATTRIBUTES Label; }
160
+ [DllImport("advapi32.dll", SetLastError = true)]
161
+ public static extern bool GetTokenInformation(IntPtr token, int infoClass, IntPtr info, uint length, out uint returned);
162
+ [DllImport("advapi32.dll", SetLastError = true)]
163
+ public static extern IntPtr GetSidSubAuthority(IntPtr sid, uint index);
164
+ [DllImport("advapi32.dll", SetLastError = true)]
165
+ public static extern IntPtr GetSidSubAuthorityCount(IntPtr sid);
166
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
167
+ private static extern Microsoft.Win32.SafeHandles.SafeFileHandle CreateFileW(
168
+ string path, uint access, uint share, IntPtr security, uint disposition, uint flags, IntPtr template);
169
+ // OPEN_EXISTING never creates or changes contents. Windows evaluates the caller's
170
+ // effective access, including group attributes, denies, ownership and inheritance.
171
+ public static bool HasAccess(string path, uint access) {
172
+ using (var handle = CreateFileW(path, access, 7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) {
173
+ if (!handle.IsInvalid) { return true; }
174
+ int error = Marshal.GetLastWin32Error();
175
+ if (error == 5) { return false; }
176
+ throw new System.ComponentModel.Win32Exception(error, "CreateFileW access check: " + path);
177
+ }
178
+ }
179
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode)]
180
+ private static extern uint SetNamedSecurityInfoW(string path, int type, uint information,
181
+ IntPtr owner, IntPtr group, byte[] dacl, IntPtr sacl);
182
+ public static void SetDacl(string path, string sddl) {
183
+ var descriptor = new System.Security.AccessControl.RawSecurityDescriptor(sddl);
184
+ byte[] dacl = null;
185
+ if (descriptor.DiscretionaryAcl != null) {
186
+ dacl = new byte[descriptor.DiscretionaryAcl.BinaryLength];
187
+ descriptor.DiscretionaryAcl.GetBinaryForm(dacl, 0);
188
+ }
189
+ bool isProtected = (descriptor.ControlFlags & System.Security.AccessControl.ControlFlags.DiscretionaryAclProtected) != 0;
190
+ // Only the DACL and its inheritance protection are written: never owner, group or SACL.
191
+ uint result = SetNamedSecurityInfoW(path, 1, 4u | (isProtected ? 0x80000000u : 0x20000000u),
192
+ IntPtr.Zero, IntPtr.Zero, dacl, IntPtr.Zero);
193
+ if (result != 0) { throw new System.ComponentModel.Win32Exception((int)result, "Write DACL: " + path); }
194
+ }
195
+ public static int Level(IntPtr token) {
196
+ uint length;
197
+ GetTokenInformation(token, 25, IntPtr.Zero, 0, out length);
198
+ if (length == 0) { throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "Read integrity information length"); }
199
+ IntPtr buffer = Marshal.AllocHGlobal((int)length);
200
+ try {
201
+ if (!GetTokenInformation(token, 25, buffer, length, out length)) { throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "Read integrity information"); }
202
+ IntPtr sid = Marshal.PtrToStructure<TOKEN_MANDATORY_LABEL>(buffer).Label.Sid;
203
+ byte count = Marshal.ReadByte(GetSidSubAuthorityCount(sid));
204
+ return Marshal.ReadInt32(GetSidSubAuthority(sid, (uint)(count - 1)));
205
+ } finally { Marshal.FreeHGlobal(buffer); }
206
+ }
207
+ '@
208
+ }
209
+ }
210
+
211
+ function Get-IntegritySid {
212
+ try {
213
+ $level = [Dsh.TokenInfo]::Level([System.Security.Principal.WindowsIdentity]::GetCurrent().Token)
214
+ switch ($level) {
215
+ 0 { return 'S-1-16-0 (Untrusted)' }
216
+ 4096 { return 'S-1-16-4096 (Low)' }
217
+ 8192 { return 'S-1-16-8192 (Medium)' }
218
+ 12288 { return 'S-1-16-12288 (High)' }
219
+ 16384 { return 'S-1-16-16384 (System)' }
220
+ default { return "S-1-16-$level (unrecognized level)" }
221
+ }
222
+ } catch {
223
+ Write-Report observation integrity '' unknown 'The token integrity level could not be read; do not infer that the caller is elevated or confined.' @{ error = (Get-HResultChain $_.Exception) }
224
+ return 'unknown'
225
+ }
226
+ }
227
+
228
+ function Get-NormalizedPath {
229
+ param([string]$Value)
230
+ $full = [System.IO.Path]::GetFullPath($Value)
231
+ $root = [System.IO.Path]::GetPathRoot($full)
232
+ if ($full.Length -le $root.Length) { return $root }
233
+ return $full.TrimEnd('\', '/')
234
+ }
235
+
236
+ function Test-DangerousRoot {
237
+ param([string]$FullPath)
238
+ $trimmed = (Get-NormalizedPath $FullPath).TrimEnd('\')
239
+ if ($trimmed -match '^[A-Za-z]:$') { return 'drive root' }
240
+ if ($trimmed -ieq ([System.IO.Path]::GetFullPath($env:USERPROFILE).TrimEnd('\'))) { return 'user profile root' }
241
+ if ($trimmed -ieq ([System.IO.Path]::GetFullPath($env:WINDIR).TrimEnd('\'))) { return 'Windows directory' }
242
+ foreach ($entry in @(
243
+ @{ directory = $env:LOCALAPPDATA; child = 'Packages' },
244
+ @{ directory = $env:ProgramFiles; child = 'WindowsApps' },
245
+ @{ directory = $env:ProgramW6432; child = 'WindowsApps' }
246
+ )) {
247
+ if (-not $entry.directory) { continue }
248
+ $protected = Get-NormalizedPath (Join-Path $entry.directory $entry.child)
249
+ if ($trimmed -ieq $protected -or (Test-UnderRoot $trimmed $protected)) { return 'managed application directory' }
250
+ }
251
+ return $null
252
+ }
253
+
254
+ function Test-UnderRoot {
255
+ param([string]$FullPath, [string]$Root, [switch]$AllowEqual)
256
+ $r = Get-NormalizedPath $Root
257
+ $c = Get-NormalizedPath $FullPath
258
+ if ($AllowEqual -and $c -ieq $r) { return $true }
259
+ return $c -ine $r -and $c.StartsWith($r.TrimEnd('\') + '\', [System.StringComparison]::OrdinalIgnoreCase)
260
+ }
261
+
262
+ function Get-RepairRefusal {
263
+ param([string]$FullPath, [string]$Root, [switch]$AllowEqual)
264
+ if (-not (Test-UnderRoot -FullPath $FullPath -Root $Root -AllowEqual:$AllowEqual)) { return "$FullPath is outside -AllowRoot" }
265
+ $danger = Test-DangerousRoot -FullPath $FullPath
266
+ if ($danger) { return "$FullPath is a $danger" }
267
+ # Checking every component also covers a junction used as AllowRoot itself.
268
+ foreach ($component in (Get-Ancestors -FullPath $FullPath)) {
269
+ $item = Get-Item -LiteralPath $component -Force
270
+ if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
271
+ return "$FullPath traverses a reparse point: $component"
272
+ }
273
+ }
274
+ return $null
275
+ }
276
+
277
+ # A caller that cannot provision a directory also cannot confine a child anywhere
278
+ # under it, so one approved run examines that subtree for explicit package allow
279
+ # ACEs instead of leaving them for a second call. Directories only: DSH grants and
280
+ # traverses directories, and no reparse point or managed application tree is entered.
281
+ function Get-SubtreePackageSources {
282
+ param([string]$Root, [int]$Limit)
283
+ $sources = @()
284
+ $visited = 0
285
+ $enqueued = 0
286
+ $failed = 0
287
+ $truncated = $false
288
+ $queue = [System.Collections.Generic.Queue[string]]::new()
289
+ # Unreadable directories are skipped, never guessed at: the caller reports the count.
290
+ try {
291
+ foreach ($child in [System.IO.Directory]::EnumerateDirectories($Root)) {
292
+ if ($enqueued -ge $Limit) { $truncated = $true; break }
293
+ $queue.Enqueue($child); $enqueued++
294
+ }
295
+ } catch [System.UnauthorizedAccessException] { $failed++ }
296
+ catch [System.IO.IOException] { $failed++ }
297
+ while ($queue.Count -gt 0) {
298
+ if ($visited -ge $Limit) { $truncated = $true; break }
299
+ $current = $queue.Dequeue()
300
+ $visited++
301
+ if (Test-DangerousRoot -FullPath $current) { continue }
302
+ $item = Get-Item -LiteralPath $current -Force -ErrorAction SilentlyContinue
303
+ if ($null -eq $item) { $failed++; continue }
304
+ if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { continue }
305
+ try {
306
+ $acl = Get-Acl -LiteralPath $current
307
+ foreach ($rule in $acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])) {
308
+ if ($rule.AccessControlType -eq 'Allow' -and $rule.IdentityReference.Value -match $PACKAGE_SID) { $sources += $current; break }
309
+ }
310
+ } catch [System.UnauthorizedAccessException] { $failed++ }
311
+ catch [System.IO.IOException] { $failed++ }
312
+ try {
313
+ foreach ($child in [System.IO.Directory]::EnumerateDirectories($current)) {
314
+ if ($enqueued -ge $Limit) { $truncated = $true; break }
315
+ $queue.Enqueue($child); $enqueued++
316
+ }
317
+ } catch [System.UnauthorizedAccessException] { $failed++ }
318
+ catch [System.IO.IOException] { $failed++ }
319
+ }
320
+ return [ordered]@{ sources = @($sources); visited = $visited; truncated = $truncated; failed = $failed }
321
+ }
322
+
323
+ function Get-OwnDaclSddl {
324
+ param([string]$Sddl)
325
+ # Inherited ACEs follow their parent: repeating them in a saved DACL forces them
326
+ # back onto the object on restore and stops matching once the parent re-derives
327
+ # them. Keep the observed control flags so the result still carries the
328
+ # object's inheritance protection, and keep every ACE the object owns.
329
+ $flags = [regex]::Match($Sddl, '^D:([A-Z]*)').Groups[1].Value
330
+ $aces = @([regex]::Matches($Sddl, '\([^()]*\)') | ForEach-Object { $_.Value } | Where-Object { ($_ -split ';')[1] -notmatch 'ID' })
331
+ return 'D:{0}{1}' -f $flags, ($aces -join '')
332
+ }
333
+
334
+ function Get-AceSet {
335
+ param([string]$Sddl)
336
+ return @([regex]::Matches($Sddl, '\([^()]*\)') | ForEach-Object { $_.Value } | Sort-Object)
337
+ }
338
+
339
+ function Save-AclBackup {
340
+ param([string]$FullPath, [string]$Directory, [string]$Root)
341
+ $fullDirectory = [System.IO.Path]::GetFullPath($Directory)
342
+ if (-not (Test-Path -LiteralPath $fullDirectory)) {
343
+ Invoke-ReportedOperation create_backup_directory $fullDirectory 'The requested recovery directory does not exist; create it before writing backup files.' files {
344
+ New-Item -ItemType Directory -Path $fullDirectory | Out-Null
345
+ }
346
+ }
347
+ $record = Join-Path $fullDirectory ('acl-backup-{0}.json' -f ([guid]::NewGuid().ToString('N')))
348
+ # The skill's extracted directory expires with its registration. Recovery must
349
+ # remain runnable from the recovery directory after that registration is gone.
350
+ $restoreScript = $record + '.ps1'
351
+ $command = "pwsh -NoProfile -File '{0}' -Path '{1}' -AllowRoot '{2}' -Restore '{3}'" -f
352
+ $restoreScript.Replace("'", "''"), $FullPath.Replace("'", "''"), ([System.IO.Path]::GetFullPath($Root)).Replace("'", "''"), $record.Replace("'", "''")
353
+ Write-Report decision backup $FullPath selected 'The recovery record and an independent recovery script are written before the ACL change.' @{ files = @($record, $restoreScript) }
354
+ Invoke-ReportedOperation backup $FullPath 'Save the original DACL and an independent recovery command before changing permissions.' files {
355
+ $backupAcl = Get-Acl -LiteralPath $FullPath
356
+ $observed = $backupAcl.GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access)
357
+ @{
358
+ Path = $FullPath
359
+ Dacl = Get-OwnDaclSddl -Sddl $observed
360
+ Protected = $backupAcl.AreAccessRulesProtected
361
+ Observed = $observed
362
+ } | ConvertTo-Json | Set-Content -LiteralPath $record -Encoding utf8
363
+ Copy-Item -LiteralPath $PSCommandPath -Destination $restoreScript
364
+ }
365
+ $script:recoveries.Add(@{ path = $FullPath; record = $record; script = $restoreScript; command = $command; attempted = $false; restored = $false })
366
+ Write-Report observation recovery $FullPath available 'These files can restore the saved DACL; no rollback has been executed.' $script:recoveries[-1]
367
+ Write-Line ('BACKUP {0} -> {1}' -f $FullPath, $record)
368
+ Write-Line ('ROLLBACK {0}' -f $command)
369
+ }
370
+ function Restore-SavedDacl {
371
+ param([string]$FullPath, [string]$Record, [string]$Root)
372
+ $refusal = Get-RepairRefusal -FullPath $FullPath -Root $Root -AllowEqual
373
+ if ($refusal) { throw [System.ArgumentException]::new("RESTORE_REFUSED $refusal") }
374
+ $saved = Invoke-ReportedOperation read_recovery $Record 'Read the recovery record and verify it belongs to the requested path before restoring its DACL.' none {
375
+ Get-Content -LiteralPath $Record -Raw | ConvertFrom-Json
376
+ }
377
+ if ($saved.Path -isnot [string] -or $saved.Path -ine $FullPath -or $saved.Dacl -isnot [string]) {
378
+ throw [System.ArgumentException]::new('RESTORE_REFUSED backup does not describe the requested path')
379
+ }
380
+ if (-not [Dsh.TokenInfo]::HasAccess($FullPath, 0x40000)) { throw 'RESTORE_REFUSED the caller lacks WRITE_DAC' }
381
+ Invoke-ReportedOperation restore_dacl $FullPath 'Restore the requested backup DACL and inheritance protection, preserving owner and SACL.' acl {
382
+ [Dsh.TokenInfo]::SetDacl($FullPath, $saved.Dacl)
383
+ }
384
+ $restoredAcl = Invoke-ReportedOperation read_restored_dacl $FullPath 'Read the restored DACL to compare it with the saved record.' none {
385
+ Get-Acl -LiteralPath $FullPath
386
+ }
387
+ # Inherited entries follow the parent, so the same object can render a different
388
+ # full DACL before and after a correct restore. Compare the own ACEs and the
389
+ # protection state, which the restore owns.
390
+ $restoredProtected = $restoredAcl.AreAccessRulesProtected
391
+ $actual = Get-OwnDaclSddl -Sddl $restoredAcl.GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access)
392
+ $expectedAces = Get-AceSet -Sddl $saved.Dacl
393
+ $actualAces = Get-AceSet -Sddl $actual
394
+ $restored = (@($expectedAces) -join "`n") -eq (@($actualAces) -join "`n") -and $restoredProtected -eq [bool]$saved.Protected
395
+ Write-Report verification restore $FullPath $(if ($restored) { 'verified' } else { 'failed' }) 'Compare the restored own ACEs and protection state with the recovery record.' @{ expectedAces = @($expectedAces); actualAces = @($actualAces); protected = $restoredProtected; expectedProtected = [bool]$saved.Protected }
396
+ if (-not $restored) { throw 'RESTORE_FAILED the restored DACL differs from the backup' }
397
+ $script:restored++
398
+ Write-Line ('RESTORED {0}' -f $FullPath)
399
+ }
400
+
401
+ function Get-ObjectFacts {
402
+ param([string]$FullPath, [string]$MeSid, [switch]$CompactRecord)
403
+ $facts = [ordered]@{
404
+ Object = $FullPath
405
+ Readable = $false
406
+ Error = ''
407
+ PackageAces = @()
408
+ OtherAppContainerSids = @()
409
+ Owner = ''
410
+ OwnerIsMe = $false
411
+ MyRights = ''
412
+ HasWriteDac = $null
413
+ HasWriteOwner = $null
414
+ Aces = @()
415
+ Errors = @()
416
+ LowLabel = $null
417
+ AclLines = @()
418
+ }
419
+ try {
420
+ # Get-Acl reads the security descriptor directly. On .NET Core the
421
+ # FileSystemInfo.GetAccessControl() form is an extension method, which
422
+ # PowerShell cannot invoke with instance syntax.
423
+ $acl = Get-Acl -LiteralPath $FullPath
424
+ $facts.Readable = $true
425
+ } catch {
426
+ $facts.Error = Get-HResultChain -Exception $_.Exception
427
+ Write-Report observation inspect_acl $FullPath unreadable 'The ACL read failed; permissions and the cause of denial remain unknown.' @{ error = $facts.Error }
428
+ return $facts
429
+ }
430
+ foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) {
431
+ $sid = $rule.IdentityReference.Value
432
+ $facts.Aces += [ordered]@{
433
+ sid = $sid; type = [string]$rule.AccessControlType; rights = [string]$rule.FileSystemRights
434
+ inherited = $rule.IsInherited; inheritance = [string]$rule.InheritanceFlags; propagation = [string]$rule.PropagationFlags
435
+ }
436
+ if ($sid -match $PACKAGE_SID -and $rule.AccessControlType -eq 'Allow') {
437
+ $facts.PackageAces += ('SID={0} INHERITED={1} INHERITABLE={2} RIGHTS={3}' -f $sid, $rule.IsInherited, $rule.InheritanceFlags, $rule.FileSystemRights)
438
+ } elseif ($sid -match '^S-1-15-' -and $sid -notmatch $PACKAGE_SID) {
439
+ $facts.OtherAppContainerSids += $sid
440
+ }
441
+ if ($sid -eq $MeSid -and $rule.AccessControlType -eq 'Allow') {
442
+ $facts.MyRights = ($facts.MyRights + ';' + [string]$rule.FileSystemRights).Trim(';')
443
+ }
444
+ }
445
+ try {
446
+ $facts.Owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
447
+ $facts.OwnerIsMe = ($facts.Owner -eq $MeSid)
448
+ } catch {
449
+ $facts.Owner = 'unreadable'
450
+ $facts.Errors += @{ operation = 'read_owner'; error = (Get-HResultChain $_.Exception) }
451
+ }
452
+ foreach ($check in @(@{ field = 'HasWriteDac'; mask = 0x40000 }, @{ field = 'HasWriteOwner'; mask = 0x80000 })) {
453
+ try { $facts[$check.field] = [Dsh.TokenInfo]::HasAccess($FullPath, $check.mask) }
454
+ catch { $facts.Errors += @{ operation = $check.field; error = (Get-HResultChain $_.Exception) } }
455
+ }
456
+ # The mandatory label lives in the SACL. Reading the SACL needs a privilege while
457
+ # icacls prints the label without one, but icacls renders it by name, which is
458
+ # localized: try the integrity SID first and keep the English name as a fallback.
459
+ try {
460
+ $facts.AclLines = @(icacls $FullPath 2>&1 | ForEach-Object { [string]$_ })
461
+ if ($LASTEXITCODE -ne 0) { throw "icacls exit ${LASTEXITCODE}: $($facts.AclLines -join "`n")" }
462
+ $text = $facts.AclLines -join "`n"
463
+ # An unrecognized localized label is unknown, not evidence of a Low label.
464
+ if ($text -match $LOW_LABEL_SID -or $text -match 'Mandatory Label\\Low Mandatory Level') { $facts.LowLabel = $true }
465
+ } catch {
466
+ $facts.Errors += @{ operation = 'icacls'; error = (Get-HResultChain $_.Exception) }
467
+ }
468
+ Write-Report observation inspect_acl $FullPath $(if ($facts.Errors.Count) { 'partial' } else { 'read' }) 'Read the ACL and check effective WRITE_DAC and WRITE_OWNER; observed ACEs alone do not identify which rule caused a denial.' @{
469
+ owner = $facts.Owner; writeDac = $facts.HasWriteDac; writeOwner = $facts.HasWriteOwner
470
+ # A re-read after a change repeats an ACL this run already recorded: keep the
471
+ # full ACE list in memory, but do not print it twice.
472
+ aces = $(if ($CompactRecord) { @() } else { $facts.Aces }); acesOmitted = [bool]$CompactRecord
473
+ lowLabel = $facts.LowLabel; errors = $facts.Errors
474
+ }
475
+ return $facts
476
+ }
477
+
478
+ function Get-Ancestors {
479
+ param([string]$FullPath)
480
+ $list = @()
481
+ $current = Get-NormalizedPath $FullPath
482
+ while ($true) {
483
+ $list += $current
484
+ $parent = [System.IO.Path]::GetDirectoryName($current)
485
+ if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) { break }
486
+ $current = $parent
487
+ }
488
+ return $list
489
+ }
490
+
491
+ # --- main ---------------------------------------------------------------
492
+
493
+ $operations = [System.Collections.Generic.List[object]]::new()
494
+ $recoveries = [System.Collections.Generic.List[object]]::new()
495
+ $reports = [System.Collections.Generic.List[object]]::new()
496
+ $reportPath = $null
497
+ $reportWriter = $null
498
+ $requestedPaths = @()
499
+ $rollbackStatus = 'not-needed'
500
+ $fixed = 0
501
+ $granted = 0
502
+ $refused = 0
503
+ $restored = 0
504
+ $exitCode = 0
505
+ $observationFailures = 0
506
+ $scanTruncated = $false
507
+ $currentPath = ''
508
+ $mode = if ($Restore) { 'restore' } else { 'repair' }
509
+
510
+ try {
511
+ $requestedPaths = @($Path | ForEach-Object { [System.IO.Path]::GetFullPath($_) })
512
+ Write-Report invocation $mode '' started 'Inspect the requested paths, then repair the ACL problems this run can prove, before anything else is attempted.' @{
513
+ paths = $Path; allowRoot = $AllowRoot; outputDirectory = $Out; recoveryRecord = $Restore
514
+ }
515
+ if (-not $AllowRoot) { throw [System.ArgumentException]::new('Every modification requires -AllowRoot') }
516
+ if (-not $Restore -and -not $Out) { throw [System.ArgumentException]::new('A repair requires -Out for its recovery artifacts') }
517
+ if ($Restore -and $Path.Count -ne 1) { throw [System.ArgumentException]::new('-Restore requires exactly one -Path') }
518
+ if ($Out) {
519
+ Invoke-ReportedOperation prepare_report $Out 'Create the recovery directory and a new JSONL report that keeps every record, because tool output is truncated to its tail.' files {
520
+ $directory = [System.IO.Path]::GetFullPath($Out)
521
+ [System.IO.Directory]::CreateDirectory($directory) | Out-Null
522
+ $script:reportPath = Join-Path $directory ('acl-report-{0}.jsonl' -f [guid]::NewGuid().ToString('N'))
523
+ $stream = [System.IO.File]::Open($script:reportPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write)
524
+ $script:reportWriter = [System.IO.StreamWriter]::new($stream, [System.Text.UTF8Encoding]::new($false))
525
+ foreach ($record in $script:reports) { $script:reportWriter.WriteLine(($record | ConvertTo-Json -Depth 12 -Compress)) }
526
+ }
527
+ }
528
+ $identity = Get-CurrentIdentity
529
+ $meSid = $identity.User.Value
530
+ Invoke-ReportedOperation initialize '' 'Load read-only access checks and DACL-only writes before inspecting permissions.' none { Initialize-NativeApi }
531
+ $integrity = Get-IntegritySid
532
+ Write-Line ('CALLER SID={0} INTEGRITY={1}' -f $meSid, $integrity)
533
+ Write-Report observation caller '' read 'The caller token determines effective access; unconfined execution does not imply elevation.' @{ sid = $meSid; integrity = $integrity }
534
+ :paths foreach ($requested in $Path) {
535
+ $currentPath = $requested
536
+ $full = Get-NormalizedPath $requested
537
+ $currentPath = $full
538
+ Write-Line ('PATH={0}' -f $full)
539
+ if (-not (Test-Path -LiteralPath $full)) {
540
+ Write-Line ' MISSING'
541
+ Write-Line 'VERDICT=NOT_THIS_CLASS'
542
+ Write-Decision $mode $full skipped 'The requested path does not exist; no ACL was read or changed for it.'
543
+ $refused++
544
+ break paths
545
+ }
546
+
547
+ $factsByPath = @{}
548
+ $inspectOrder = @()
549
+ $packageTargets = @()
550
+ $targetFacts = $null
551
+ foreach ($ancestor in (Get-Ancestors -FullPath $full)) {
552
+ if (-not (Test-Path -LiteralPath $ancestor)) { continue }
553
+ $facts = Get-ObjectFacts -FullPath $ancestor -MeSid $meSid
554
+ $factsByPath[$facts.Object] = $facts
555
+ $inspectOrder += $facts.Object
556
+ if ($ancestor -eq $full) { $targetFacts = $facts }
557
+ Write-Line (' OBJECT={0} READABLE={1}{2}' -f $facts.Object, $facts.Readable, $(if ($facts.Readable) { '' } else { ' ERROR=' + $facts.Error }))
558
+ if ($facts.Readable) {
559
+ Write-Line (' OWNER={0} IS_CURRENT_USER={1} MY_RIGHTS=[{2}] WRITE_DAC={3} WRITE_OWNER={4} LOW_LABEL={5}' -f $facts.Owner, $facts.OwnerIsMe, $facts.MyRights, $facts.HasWriteDac, $facts.HasWriteOwner, $facts.LowLabel)
560
+ foreach ($ace in $facts.PackageAces) { Write-Line (' PACKAGE_ACE {0}' -f $ace) }
561
+ foreach ($sid in ($facts.OtherAppContainerSids | Sort-Object -Unique)) { Write-Line (' OTHER_S1_15 SID={0} (reported only)' -f $sid) }
562
+ }
563
+ if ($facts.PackageAces.Count -gt 0) { $packageTargets += $facts.Object }
564
+ }
565
+
566
+ # The precondition is judged on the requested path itself. Ancestors above the
567
+ # tree DSH grants are not part of that grant, so their ownership and rights would
568
+ # otherwise mark every healthy path as a precondition failure.
569
+ if ($null -eq $targetFacts) { throw "The requested path disappeared before its ACL could be inspected: $full" }
570
+ $unreadable = -not $targetFacts.Readable
571
+ $needsPrecondition = (-not $unreadable) -and
572
+ ((-not $targetFacts.HasWriteDac) -or (-not $targetFacts.HasWriteOwner))
573
+
574
+ # A directory the caller cannot provision is the workspace root half the time: the sandbox
575
+ # reports its own failure there while the conflicting entry sits deeper. Examine that
576
+ # subtree before the verdict so the classification and its packageObjects describe what
577
+ # this run will actually repair.
578
+ $ownsRoot = $full -ieq (Get-NormalizedPath $AllowRoot)
579
+ if (-not $Restore -and $targetFacts.Errors.Count -eq 0 -and [System.IO.Directory]::Exists($full) -and ($needsPrecondition -or $ownsRoot)) {
580
+ $scan = Get-SubtreePackageSources -Root $full -Limit $SCAN_LIMIT
581
+ foreach ($object in $scan.sources) {
582
+ if ($packageTargets -notcontains $object) { $packageTargets += $object }
583
+ if (-not $factsByPath.ContainsKey($object)) {
584
+ $scanned = Get-ObjectFacts -FullPath $object -MeSid $meSid
585
+ $factsByPath[$scanned.Object] = $scanned
586
+ }
587
+ }
588
+ Write-Report observation subtree_scan $full $(if ($scan.failed -gt 0) { 'partial' } else { 'read' }) 'The caller cannot provision this directory, or it is the authorized root, so explicit package allow ACEs under it were collected in the same run; no reparse point or managed application tree is entered.' @{
589
+ visited = $scan.visited; packageSources = @($scan.sources); truncated = [bool]$scan.truncated; unreadable = [int]$scan.failed
590
+ }
591
+ Write-Line ('SCAN {0} VISITED={1} PACKAGE_SOURCES={2}{3}{4}' -f $full, $scan.visited, @($scan.sources).Count, $(if ($scan.truncated) { ' TRUNCATED' } else { '' }), $(if ($scan.failed -gt 0) { " UNREADABLE=$($scan.failed)" } else { '' }))
592
+ if ($scan.truncated) { $script:scanTruncated = $true }
593
+ }
594
+
595
+ if ($targetFacts.Errors.Count -gt 0) {
596
+ $verdict = 'INCOMPLETE'
597
+ $reason = 'Some observations failed; missing evidence is not evidence that a right is absent or a repair is safe.'
598
+ } elseif ($packageTargets.Count -gt 0) {
599
+ $blocked = @($packageTargets | Where-Object { -not $factsByPath[$_].HasWriteDac })
600
+ $verdict = if ($needsPrecondition -or $blocked.Count -gt 0) { 'BOTH' } else { 'CULPRIT' }
601
+ $reason = 'Package allow ACEs were observed. Required access checks determine whether the caller can perform the requested repair; other causes remain possible.'
602
+ } elseif ($unreadable) {
603
+ $verdict = 'UNREADABLE'
604
+ $reason = 'The requested ACL could not be read; its entries and the cause of denial are unknown.'
605
+ } elseif ($needsPrecondition) {
606
+ $verdict = 'PRECONDITION'
607
+ $reason = 'The caller cannot open the requested object with both WRITE_DAC and WRITE_OWNER. The reported allow and deny ACEs are evidence, not an attribution to one rule.'
608
+ } else {
609
+ $verdict = 'NOT_THIS_CLASS'
610
+ $reason = 'No package allow ACE was observed and both required rights are available. Other ACL restrictions and causes of the original failure are not ruled out.'
611
+ }
612
+ Write-Line ('VERDICT={0}' -f $verdict)
613
+ Write-Report decision classify $full $verdict $reason @{ writeDac = $targetFacts.HasWriteDac; writeOwner = $targetFacts.HasWriteOwner; packageObjects = @($packageTargets) }
614
+
615
+ if ($Restore) {
616
+ Restore-SavedDacl -FullPath $full -Record $Restore -Root $AllowRoot
617
+ continue
618
+ }
619
+ if ($targetFacts.Errors.Count -gt 0) {
620
+ Write-Decision $mode $full refused 'Required observations are incomplete; no repair was attempted for this path.'
621
+ $refused++
622
+ break paths
623
+ }
624
+
625
+ # Nothing is changed while a package source or a grant target is out of reach: a
626
+ # repair that cannot finish would leave the tree half repaired before refusing.
627
+ foreach ($packagePath in $packageTargets) {
628
+ $refusal = Get-RepairRefusal -FullPath $packagePath -Root $AllowRoot -AllowEqual
629
+ if ($refusal) { Write-Line ('REPAIR_REFUSED {0}' -f $refusal); Write-Decision $mode $packagePath refused $refusal; $refused++; break paths }
630
+ if ($factsByPath[$packagePath].Errors.Count -gt 0) {
631
+ Write-Decision $mode $packagePath refused 'The ACL observations are incomplete; collateral changes could not be verified.'
632
+ $refused++
633
+ break paths
634
+ }
635
+ }
636
+
637
+ # Missing WRITE_DAC or WRITE_OWNER on a directory in the chain is what stops DSH
638
+ # from provisioning the workspace grant. One approved run repairs every such
639
+ # directory, including the authorized root itself.
640
+ $grantTargets = @($inspectOrder | Where-Object {
641
+ [System.IO.Directory]::Exists($_) -and $factsByPath[$_].Readable -and $factsByPath[$_].Errors.Count -eq 0 -and
642
+ ((-not $factsByPath[$_].HasWriteDac) -or (-not $factsByPath[$_].HasWriteOwner)) -and
643
+ -not (Get-RepairRefusal -FullPath $_ -Root $AllowRoot -AllowEqual)
644
+ })
645
+ # Writing a DACL needs WRITE_DAC: refuse a target that lacks it before anything is
646
+ # changed, instead of failing mid-repair and marking a recovery for an object that
647
+ # the rollback then cannot restore either.
648
+ foreach ($object in $grantTargets) {
649
+ if (-not $factsByPath[$object].HasWriteDac) {
650
+ Write-Line ('REPAIR_REFUSED {0} needs WRITE_DAC; stop for permission-policy review' -f $object)
651
+ Write-Decision $mode $object refused 'Effective WRITE_DAC is absent; adding the grant requires that right.'
652
+ $refused++
653
+ break paths
654
+ }
655
+ }
656
+ if ($grantTargets.Count -gt 0) {
657
+ Write-Report decision grant_targets $full selected 'These directories lack effective WRITE_DAC or WRITE_OWNER; each is backed up, then receives a FullControl allow ACE for the current user.' @{ paths = @($grantTargets) }
658
+ }
659
+ foreach ($object in $grantTargets) {
660
+ $grantBefore = $factsByPath[$object]
661
+ Save-AclBackup -FullPath $object -Directory $Out -Root $AllowRoot
662
+ $grantAcl = Get-Acl -LiteralPath $object
663
+ $grantAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
664
+ [System.Security.Principal.SecurityIdentifier]::new($meSid),
665
+ [System.Security.AccessControl.FileSystemRights]::FullControl,
666
+ [System.Security.AccessControl.AccessControlType]::Allow))
667
+ Invoke-ReportedOperation grant_dacl $object "WRITE_DAC or WRITE_OWNER is missing; add a FullControl allow ACE for $meSid while preserving deny ACEs, owner and SACL." acl {
668
+ [Dsh.TokenInfo]::SetDacl($object, $grantAcl.GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access))
669
+ }
670
+ $grantAfter = Get-ObjectFacts -FullPath $object -MeSid $meSid -CompactRecord
671
+ $factsByPath[$object] = $grantAfter
672
+ $verified = $grantAfter.Readable -and $grantAfter.Errors.Count -eq 0 -and $grantAfter.HasWriteDac -and $grantAfter.HasWriteOwner
673
+ Write-Report verification grant $object $(if ($verified) { 'verified' } else { 'failed' }) 'The DACL write completed; recheck effective access before claiming that provisioning can succeed. On failure, restore this invocation and stop.' @{
674
+ before = @{ writeDac = $grantBefore.HasWriteDac; writeOwner = $grantBefore.HasWriteOwner }
675
+ after = @{ writeDac = $grantAfter.HasWriteDac; writeOwner = $grantAfter.HasWriteOwner }
676
+ recovery = $recoveries[-1].command
677
+ }
678
+ if ($verified) {
679
+ Write-Line ('GRANTED {0} SID={1}' -f $object, $meSid); $granted++
680
+ } else {
681
+ Write-Line ('GRANT_FAILED {0}; effective WRITE_DAC and WRITE_OWNER were not both confirmed; restore this invocation and stop' -f $object)
682
+ $refused++
683
+ break paths
684
+ }
685
+ }
686
+
687
+ $sources = @($packageTargets | Where-Object {
688
+ @($factsByPath[$_].Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited }).Count -gt 0
689
+ })
690
+ # Ancestor first, for chain and scan-discovered sources alike.
691
+ $sources = @($sources | Sort-Object -Property @{ Expression = { $_.Split([char]92).Count } }, @{ Expression = { $_ } })
692
+ foreach ($source in $sources) {
693
+ if (-not $factsByPath[$source].HasWriteDac) {
694
+ Write-Line ('REPAIR_REFUSED {0} needs WRITE_DAC; stop for permission-policy review' -f $source)
695
+ Write-Decision $mode $source refused 'Effective WRITE_DAC is absent; removing a package allow ACE requires that right.'
696
+ $refused++
697
+ break paths
698
+ }
699
+ }
700
+ if ($sources.Count -gt 0) {
701
+ Write-Report decision remove_package_sources $full selected 'Remove explicit package allow ACEs from their sources, ancestor first, leaving inheritance enabled so inherited copies follow their repaired source.' @{
702
+ sources = @($sources); affectedPaths = @($packageTargets)
703
+ }
704
+ }
705
+ foreach ($source in $sources) {
706
+ Save-AclBackup -FullPath $source -Directory $Out -Root $AllowRoot
707
+ $sids = @($factsByPath[$source].Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited } | ForEach-Object { $_.sid } | Sort-Object -Unique)
708
+ foreach ($sid in $sids) {
709
+ Invoke-ReportedOperation remove_package_allow $source "Remove the observed package allow ACE for $sid; preserve deny ACEs and all other principals." acl {
710
+ $output = @(icacls $source /remove:g "*$sid" 2>&1)
711
+ if ($LASTEXITCODE -ne 0) { throw "icacls removal exit $($LASTEXITCODE): $($output -join [char]10)" }
712
+ }
713
+ }
714
+ }
715
+
716
+ # A package deny can share the removed allow's SID; only allow ACEs were removed.
717
+ foreach ($packagePath in $packageTargets) {
718
+ $fixBefore = $factsByPath[$packagePath]
719
+ $fixAfter = Get-ObjectFacts -FullPath $packagePath -MeSid $meSid -CompactRecord
720
+ # Compare the object's own ACEs as sorted sets: listing order and inherited
721
+ # entries follow the parent, so neither proves a collateral change.
722
+ $rowOf = { param($ace) '{0}|{1}|{2}|{3}|{4}' -f $ace.type, $ace.sid, $ace.rights, $ace.inheritance, $ace.propagation }
723
+ $ownAfter = @($fixAfter.Aces | Where-Object { -not $_.inherited } | ForEach-Object { & $rowOf $_ } | Sort-Object)
724
+ $expectedOwn = @($fixBefore.Aces | Where-Object { -not $_.inherited -and -not ($_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID) } | ForEach-Object { & $rowOf $_ } | Sort-Object)
725
+ $collateral = (@($expectedOwn) -join [char]10) -eq (@($ownAfter) -join [char]10)
726
+ $verified = $fixAfter.Readable -and $fixAfter.Errors.Count -eq 0 -and $fixAfter.PackageAces.Count -eq 0 -and $collateral
727
+ Write-Report verification fix $packagePath $(if ($verified) { 'verified' } else { 'failed' }) 'The removal command completed; verify that package allow ACEs disappeared and other own ACEs remained unchanged. On failure, restore this invocation and stop.' @{
728
+ remainingPackageAces = $fixAfter.PackageAces; otherEntriesUnchanged = $collateral
729
+ }
730
+ if ($verified) {
731
+ $removedSids = @($fixBefore.Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited } | ForEach-Object { $_.sid } | Sort-Object -Unique)
732
+ foreach ($sid in $removedSids) {
733
+ Write-Line ('FIXED {0} SID={1}' -f $packagePath, $sid); $fixed++
734
+ }
735
+ } else {
736
+ Write-Line ('FIX_FAILED {0} collateral_change={1}' -f $packagePath, (-not $collateral))
737
+ $refused++
738
+ break paths
739
+ }
740
+ }
741
+
742
+ if ($packageTargets.Count -eq 0 -and $grantTargets.Count -eq 0) {
743
+ Write-Decision $mode $full skipped 'No package allow ACE and no missing WRITE_DAC or WRITE_OWNER was observed on the inspected objects; no ACL change was needed.'
744
+ }
745
+ }
746
+ } catch {
747
+ $exitCode = if ($_.Exception -is [System.ArgumentException]) { 2 } else { 1 }
748
+ Write-Report error $mode $currentPath stopped 'Execution stopped after an exception. An interrupted write may have changed state; attempted repairs will be restored before the final summary.' @{ error = (Get-HResultChain $_.Exception); location = $_.InvocationInfo.PositionMessage }
749
+ } finally {
750
+ if ($refused -gt 0 -and $exitCode -eq 0) { $exitCode = 2 }
751
+ if ($exitCode -ne 0 -and -not $Restore) {
752
+ for ($i = $recoveries.Count - 1; $i -ge 0; $i--) {
753
+ $recovery = $recoveries[$i]
754
+ if (-not $recovery.attempted) { continue }
755
+ Write-Decision rollback $recovery.path selected 'The repair failed; restore every attempted DACL change from this invocation in reverse order before stopping.'
756
+ try {
757
+ Restore-SavedDacl -FullPath $recovery.path -Record $recovery.record -Root $AllowRoot
758
+ $recovery.restored = $true
759
+ $rollbackStatus = 'verified'
760
+ } catch {
761
+ $rollbackStatus = 'failed'
762
+ Write-Report error rollback $recovery.path stopped 'Recovery was not verified. Stop repairs and retain the pending recovery commands in reverse order.' @{ error = (Get-HResultChain $_.Exception) }
763
+ break
764
+ }
765
+ }
766
+ }
767
+ $pending = @($recoveries | Where-Object { $_.attempted -and -not $_.restored } | ForEach-Object { $_.command })
768
+ [array]::Reverse($pending)
769
+ $repaired = ($fixed + $granted) -gt 0
770
+ $nextAction = if ($rollbackStatus -eq 'failed') { 'restore_pending_then_stop' } elseif ($exitCode -ne 0 -or $observationFailures -gt 0) { 'stop' } elseif ($repaired) { 'verify_original_confined_operation' } else { 'stop' }
771
+ # The tool keeps only the tail of stdout. Recapitulate the decisions here so the
772
+ # records a reader needs survive truncation; the report file holds every record.
773
+ $recap = [ordered]@{
774
+ verdicts = @($reports | Where-Object { $_.operation -eq 'classify' } | ForEach-Object {
775
+ @{ path = $_.path; verdict = $_.status; writeDac = $_.details['writeDac']; writeOwner = $_.details['writeOwner']; packageObjects = $_.details['packageObjects'] }
776
+ })
777
+ changes = @($reports | Where-Object { $_.kind -eq 'action' -and $_.details['effect'] -eq 'acl' -and $_.status -eq 'completed' } | ForEach-Object { @{ operation = $_.operation; path = $_.path } })
778
+ verifications = @($reports | Where-Object { $_.kind -eq 'verification' } | ForEach-Object { @{ operation = $_.operation; path = $_.path; status = $_.status } })
779
+ refusals = @($reports | Where-Object { $_.kind -eq 'decision' -and $_.status -eq 'refused' } | ForEach-Object { @{ path = $_.path; reason = $_.reason } })
780
+ scans = @($reports | Where-Object { $_.operation -eq 'subtree_scan' } | ForEach-Object {
781
+ @{ path = $_.path; visited = $_.details['visited']; packageSources = @($_.details['packageSources']); truncated = $_.details['truncated']; unreadable = $_.details['unreadable'] }
782
+ })
783
+ report = $reportPath
784
+ }
785
+ $status = if ($exitCode -ne 0) { 'failed' } elseif ($observationFailures -gt 0) { 'partial' } else { 'completed' }
786
+ Write-Report summary $mode $currentPath $status 'Operation completion records API execution; verification records the observed result. Only rerunning the original confined operation can confirm its failure is resolved.' @{
787
+ exitCode = $exitCode; fixed = $fixed; granted = $granted; refused = $refused; restored = $restored
788
+ operations = @($operations.ToArray()); recoveries = @($recoveries.ToArray()); automaticRollback = $true; observationFailures = $observationFailures
789
+ rollback = $rollbackStatus; rollbackCommands = $pending; nextAction = $nextAction; scanTruncated = $scanTruncated; report = $reportPath
790
+ }
791
+ # Last on stdout, after the summary record: whatever the tool truncates, the tail
792
+ # still carries the decisions, the report path and the counts.
793
+ Write-Line ('RECAP ' + ($recap | ConvertTo-Json -Depth 8 -Compress))
794
+ if ($reportPath) { Write-Line ('REPORT_FILE {0}' -f $reportPath) }
795
+ Write-Line ('SUMMARY FIXED={0} GRANTED={1} REFUSED={2} RESTORED={3}' -f $fixed, $granted, $refused, $restored)
796
+ if ($reportWriter) { $reportWriter.Dispose() }
797
+ }
798
+ exit $exitCode
package/lib/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as assertTempRootOutsideWorkspace, i as AclWriteGrant, n as tempWriteSid, r as workspaceWriteSid, t as AclSandbox } from "./types-DxezulnA.js";
2
- export { AclSandbox, AclWriteGrant, assertTempRootOutsideWorkspace, tempWriteSid, workspaceWriteSid };
1
+ import { a as workspaceWriteSid, i as tempWriteSid, n as ACL_DIAGNOSIS_SKILL, o as AclWriteGrant, r as registerAclDiagnosisSkill, s as assertTempRootOutsideWorkspace, t as AclSandbox } from "./types-Cl_DXjhk.js";
2
+ export { ACL_DIAGNOSIS_SKILL, AclSandbox, AclWriteGrant, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid };
package/lib/runner.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as assertTempRootOutsideWorkspace, n as tempWriteSid, o as win32, r as workspaceWriteSid, t as AclSandbox } from "./types-DxezulnA.js";
1
+ import { a as workspaceWriteSid, c as win32, i as tempWriteSid, s as assertTempRootOutsideWorkspace, t as AclSandbox } from "./types-Cl_DXjhk.js";
2
2
  import { closeSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { SUBPROCESS_CONTROL_ENV, SUBPROCESS_CONTROL_FD } from "@deepseek-ai/dsh-subprocess/control";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Registers the bundled Windows sandbox ACL diagnosis skill.
3
+ *
4
+ * The provider owns a private filesystem copy of its resources so external
5
+ * PowerShell can execute them even when the package lives inside ASAR or SEA.
6
+ * Disposing the registration removes both the provider and its resource copy.
7
+ *
8
+ * @module @deepseek-ai/dsh-sandbox-windows-acl
9
+ */
10
+ import type { Context } from '@deepseek-ai/cordis';
11
+ /** Bundled skill that diagnoses Windows sandbox ACL failures. */
12
+ export declare const ACL_DIAGNOSIS_SKILL = "diagnose-windows-sandbox-acl";
13
+ /**
14
+ * Register the bundled diagnosis skill with a private resource directory owned by this fiber.
15
+ * Missing or invalid packaged assets fail registration; disposal removes the directory.
16
+ * @param ctx - Context carrying the skill registry.
17
+ */
18
+ export declare function registerAclDiagnosisSkill(ctx: Context): void;
19
+ //# sourceMappingURL=acl-skill.d.ts.map
@@ -45,6 +45,7 @@
45
45
  export { AclWriteGrant } from './grant.ts';
46
46
  export { assertTempRootOutsideWorkspace } from './path-boundary.ts';
47
47
  export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts';
48
+ export { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from './acl-skill.ts';
48
49
  /** Construction options: the workspace/temp allowlists and their distinct SID identities. */
49
50
  export interface AclSandboxOptions {
50
51
  /** Directories the confined child may write into (must exist and be caller-owned). */
@@ -1,8 +1,13 @@
1
- import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs";
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
  import { ERROR_INSUFFICIENT_BUFFER, Win32Error, allocPtrSlot, allocUint32, decodePtr, decodeUint32, drainPipe, extendWin32ProcessBindings, isNullPtr, isNullPtr as isNullPtr$1, spawnInheritedJobProcess, spawnPipedProcess, throwLastError, throwLastError as throwLastError$1, throwWin32, waitForProcessExit } from "@deepseek-ai/dsh-win32-process";
4
4
  import { createHash } from "node:crypto";
5
5
  import { createLazyRequire } from "@deepseek-ai/dsh-lazy-require";
6
+ import { readFile, rm } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { fileURLToPath } from "node:url";
9
+ import { BUNDLED_SKILL_RANK } from "@deepseek-ai/dsh-skill";
10
+ import { parse } from "yaml";
6
11
  //#region lib/types/win32-abi.js
7
12
  /** ACL/token-specific Win32 constants. */
8
13
  /** OpenProcess access required to query the current process token. */
@@ -1052,6 +1057,88 @@ function tempWriteSid(tempDir) {
1052
1057
  return `S-1-4-${digest.readUInt32LE(0) % (2 ** 30 - 1) + 1}-${digest.readUInt32LE(4) % (2 ** 30 - 1) + 1}-1`;
1053
1058
  }
1054
1059
  //#endregion
1060
+ //#region lib/types/acl-skill.js
1061
+ /**
1062
+ * Registers the bundled Windows sandbox ACL diagnosis skill.
1063
+ *
1064
+ * The provider owns a private filesystem copy of its resources so external
1065
+ * PowerShell can execute them even when the package lives inside ASAR or SEA.
1066
+ * Disposing the registration removes both the provider and its resource copy.
1067
+ *
1068
+ * @module @deepseek-ai/dsh-sandbox-windows-acl
1069
+ */
1070
+ /** Bundled skill that diagnoses Windows sandbox ACL failures. */
1071
+ const ACL_DIAGNOSIS_SKILL = "diagnose-windows-sandbox-acl";
1072
+ /** Provider name this module registers the skill under. */
1073
+ const PROVIDER = "dsh-windows-acl";
1074
+ function parseSkill(raw, path) {
1075
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(raw);
1076
+ if (frontmatter?.[1] === void 0) throw new Error(`dsh-sandbox-windows-acl: ${path} has no YAML frontmatter`);
1077
+ const metadata = parse(frontmatter[1]);
1078
+ const description = typeof metadata === "object" && metadata !== null && "description" in metadata ? metadata.description : void 0;
1079
+ if (typeof description !== "string" || description.length === 0) throw new Error(`dsh-sandbox-windows-acl: ${path} has no description`);
1080
+ return {
1081
+ description,
1082
+ content: raw.slice(frontmatter[0].length).trim()
1083
+ };
1084
+ }
1085
+ /**
1086
+ * Register the bundled diagnosis skill with a private resource directory owned by this fiber.
1087
+ * Missing or invalid packaged assets fail registration; disposal removes the directory.
1088
+ * @param ctx - Context carrying the skill registry.
1089
+ */
1090
+ function registerAclDiagnosisSkill(ctx) {
1091
+ const packaged = fileURLToPath(new URL(`../assets/${ACL_DIAGNOSIS_SKILL}/`, import.meta.url));
1092
+ ctx.effect(function* () {
1093
+ const directory = mkdtempSync(join(tmpdir(), "dsh-acl-skill-"));
1094
+ yield async () => {
1095
+ await rm(directory, {
1096
+ recursive: true,
1097
+ force: true
1098
+ });
1099
+ };
1100
+ mkdirSync(join(directory, "scripts"));
1101
+ for (const path of ["SKILL.md", "scripts/diagnose-windows-sandbox-acl.ps1"]) writeFileSync(join(directory, path), readFileSync(join(packaged, path)), {
1102
+ flag: "wx",
1103
+ mode: 384
1104
+ });
1105
+ const locator = join(directory, "SKILL.md");
1106
+ const { description } = parseSkill(readFileSync(locator, "utf8"), locator);
1107
+ const candidate = {
1108
+ name: ACL_DIAGNOSIS_SKILL,
1109
+ description,
1110
+ invocation: {
1111
+ modelInvocable: true,
1112
+ userInvocable: true
1113
+ },
1114
+ provider: PROVIDER,
1115
+ source: "bundled",
1116
+ rank: BUNDLED_SKILL_RANK,
1117
+ resourceBase: {
1118
+ kind: "directory",
1119
+ path: directory
1120
+ },
1121
+ locator
1122
+ };
1123
+ const provider = {
1124
+ name: PROVIDER,
1125
+ list: () => Promise.resolve([candidate]),
1126
+ async get(entry, options) {
1127
+ const { rank: _rank, locator: entryPath, ...summary } = entry;
1128
+ const raw = await readFile(entryPath, {
1129
+ encoding: "utf8",
1130
+ signal: options.signal
1131
+ });
1132
+ return {
1133
+ ...summary,
1134
+ content: parseSkill(raw, entryPath).content
1135
+ };
1136
+ }
1137
+ };
1138
+ yield ctx.skills.registerProvider(() => provider);
1139
+ }, "Windows ACL diagnosis skill resources");
1140
+ }
1141
+ //#endregion
1055
1142
  //#region lib/types/index.js
1056
1143
  /**
1057
1144
  * Windows ACL write-restriction sandbox backend for the DeepSeek Harness
@@ -1321,4 +1408,4 @@ var AclSandbox = class {
1321
1408
  }
1322
1409
  };
1323
1410
  //#endregion
1324
- export { assertTempRootOutsideWorkspace as a, AclWriteGrant as i, tempWriteSid as n, win32 as o, workspaceWriteSid as r, AclSandbox as t };
1411
+ export { workspaceWriteSid as a, win32 as c, tempWriteSid as i, ACL_DIAGNOSIS_SKILL as n, AclWriteGrant as o, registerAclDiagnosisSkill as r, assertTempRootOutsideWorkspace as s, AclSandbox as t };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-sandbox-windows-acl",
3
3
  "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam",
4
- "version": "0.1.7-rc.2",
4
+ "version": "0.2.0-rc.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -29,23 +29,29 @@
29
29
  "lib/index.js",
30
30
  "lib/runner.js",
31
31
  "lib/types-*.js",
32
+ "assets",
32
33
  "lib/types/**/*.d.ts"
33
34
  ],
34
35
  "license": "MIT",
35
36
  "peerDependencies": {
36
37
  "@deepseek-ai/cordis": "~4.0.4",
37
- "@deepseek-ai/dsh-subprocess": "0.1.7-rc.2"
38
+ "@deepseek-ai/dsh-skill": "0.2.0-rc.2",
39
+ "@deepseek-ai/dsh-subprocess": "0.2.0-rc.2"
38
40
  },
39
41
  "dependencies": {
40
- "koffi": "^3.1.0",
41
- "@deepseek-ai/dsh-win32-process": "0.1.7-rc.2",
42
- "@deepseek-ai/dsh-lazy-require": "0.1.7-rc.2"
42
+ "koffi": "3.1.1",
43
+ "yaml": "^2.4.2",
44
+ "@deepseek-ai/dsh-lazy-require": "0.2.0-rc.2",
45
+ "@deepseek-ai/dsh-win32-process": "0.2.0-rc.2"
43
46
  },
44
47
  "devDependencies": {
45
- "@deepseek-ai/dsh-pwsh-local": "0.1.7-rc.2",
46
- "@deepseek-ai/dsh-sandbox-local": "0.1.7-rc.2",
48
+ "@deepseek-ai/cordis-plugin-include": "~1.0.9",
49
+ "@deepseek-ai/cordis-plugin-loader": "~1.0.5",
50
+ "@deepseek-ai/dsh-pwsh-local": "0.2.0-rc.2",
51
+ "@deepseek-ai/dsh-sandbox-local": "0.2.0-rc.2",
47
52
  "@deepseek-ai/cordis": "~4.0.4",
48
- "@deepseek-ai/dsh-subprocess": "0.1.7-rc.2",
49
- "@deepseek-ai/dsh-subprocess-local": "0.1.7-rc.2"
53
+ "@deepseek-ai/dsh-skill": "0.2.0-rc.2",
54
+ "@deepseek-ai/dsh-subprocess": "0.2.0-rc.2",
55
+ "@deepseek-ai/dsh-subprocess-local": "0.2.0-rc.2"
50
56
  }
51
57
  }