@deepseek-ai/dsh-sandbox-windows-acl 0.2.0-rc.1 → 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
@@ -27,8 +27,8 @@
27
27
  en: 289e65bddf20dd79
28
28
  zh: 7ff72ed64c4b4d1c
29
29
  /deepseek-ai-dsh-sandbox-windows-acl/use-this-package/failures-and-recovery:
30
- en: 47c32eff504fcd87
31
- zh: 83c13c06ccb2e07a
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
package/README.md CHANGED
@@ -77,11 +77,11 @@ Temp isolation is per live session/workspace pair: sessions sharing a workspace
77
77
 
78
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
79
 
80
- Diagnosis leaves ACLs and existing content unchanged. `-Fix` removes explicit package allow ACEs ancestor first and verifies their inherited copies disappeared; the requested path needs effective `WRITE_DAC` and `WRITE_OWNER`. `-GrantFullControl` adds the caller's missing rights. Repairs require `WRITE_DAC`, preserve denies, owner, inheritance and SACL, and back up each changed DACL. Targets must be strictly inside `-AllowRoot`; reparse paths and managed application trees are refused. Failed repairs restore attempted changes in reverse order and exit nonzero. Follow the [bundled skill](assets/diagnose-windows-sandbox-acl/SKILL.md) for repair selection, original-operation verification and recovery.
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
81
 
82
- Every run emits `REPORT` JSON records of observations, actions, reasons and verification; unknown observations remain unknown. `-Compact -Out <directory>` persists the full report and prints a summary with all inspected paths, findings, actions, recovery commands and `nextAction`. 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.
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
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. The [parent-package example](tests/expected/parent-package-report.jsonl) retains the fixture paths and shows selected fields, with `fixturePackageAces` projecting only the synthetic test SID from `aces`; host-owned ACEs and ancestors are omitted. The [failed-grant example](tests/expected/denied-grant-report.txt) retains operation paths and reasons.
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
85
 
86
86
  -----
87
87
 
package/README.zh.md CHANGED
@@ -79,11 +79,11 @@ rmSync(tempDir, { recursive: true, force: true })
79
79
 
80
80
  此后端无法解释的拒绝交给 `assets/` 中随包发布的 `diagnose-windows-sandbox-acl` 技能诊断。当 `dsh-sandbox-local` 使用内置 Windows runner 且技能注册表可用时,`registerAclDiagnosisSkill` 会注册它。注册时提取供外部 PowerShell 使用的资源,ASAR 和 SEA 部署也适用;fiber 释放时注销提供者并删除副本。缺少资源会导致注册失败。
81
81
 
82
- 诊断不修改 ACL 或已有内容。`-Fix` 从祖先开始移除显式包允许 ACE,并验证其继承副本已消失;请求路径需要有效的 `WRITE_DAC` 和 `WRITE_OWNER`。`-GrantFullControl` 补充调用者缺少的权限。修复要求 `WRITE_DAC`,保留拒绝条目、所有者、继承和 SACL,并备份每个修改的 DACL。目标必须严格位于 `-AllowRoot` 内;重解析路径和受管理的应用目录会被拒绝。修复失败时按相反顺序恢复尝试修改的内容,并以非零状态退出。修复选择、原始操作验证及恢复流程见[内置技能](assets/diagnose-windows-sandbox-acl/SKILL.md)。
82
+ 一次运行同时完成诊断与修复。脚本没有模式开关:它读取请求路径及每一级祖先,为链路上缺少有效 `WRITE_DAC` 或 `WRITE_OWNER` 的目录补上当前用户的完全控制允许 ACE,并从祖先开始在其来源移除显式包允许 ACE,每处改动都通过重新读取来验证。修复保留拒绝条目、所有者、继承和 SACL,并备份每个被修改的 DACL。仅当对象就是 `-AllowRoot` 或严格位于其内部时才会被修改,因此工作区根目录可以自我修复;重解析路径和受管理的应用目录会被拒绝,修复需要有效的 `WRITE_DAC`。修复失败时按相反顺序恢复尝试修改的内容,并以非零状态退出。单条命令、原始操作验证及恢复流程见[内置技能](assets/diagnose-windows-sandbox-acl/SKILL.md)。
83
83
 
84
- 每次执行都会输出包含观察、操作、原因和验证结果的 `REPORT` JSON 记录;未知观察保持未知。`-Compact -Out <directory>` 保存完整报告,打印包含全部分析路径、发现、操作、恢复命令和 `nextAction` 的摘要。操作完成记录 API 执行情况,验证记录实际观察到的结果。[恢复决策](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md#acl-diagnosis-and-recovery) 说明资源归属和回滚限制。
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
85
 
86
- 检查会从请求对象逐级访问祖先,直到文件系统根目录。每条 ACL 观察的 `path` 标识实际检查的对象;分类结果的 `details.packageObjects` 列出包含包允许 ACE 的对象,但不能据此确认原始失败的原因。[父目录包 SID 示例](tests/expected/parent-package-report.jsonl) 保留测试目录路径并展示选定字段,其中 `fixturePackageAces` 只从 `aces` 提取合成测试 SID,省略机器自带的 ACE 和祖先目录。[授权失败示例](tests/expected/denied-grant-report.txt) 保留操作路径和原因。
86
+ 检查会从请求对象逐级访问祖先,直到文件系统根目录。每条 ACL 观察的 `path` 标识实际检查的对象;分类结果的 `details.packageObjects` 列出包含包允许 ACE 的对象,但不能据此确认原始失败的原因。每次包 ACE 移除都按对象验证:把记录下来的自身 ACE 与重新读取的 DACL 比对,因此与被移除允许项共用 SID 的拒绝条目仍会出现在验证详情中。
87
87
 
88
88
  -----
89
89
 
@@ -1,46 +1,63 @@
1
1
  ---
2
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. The bundled script inspects the path and every ancestor, reports observations and action reasons, and supports scoped, backed-up ACL repairs. Expected confinement denials need no ACL repair.'
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
4
  ---
5
5
 
6
6
  # Diagnose Windows sandbox ACL failures
7
7
 
8
- **Stop after any failed/refused repair or failed verification.** A completed DACL write is not a successful repair. Never continue from a failed grant to `-Fix`, repeat the grant, or remove a deny ACE. The script automatically restores attempted changes from the failed invocation; verify its rollback result. Restore earlier successful invocations in reverse order using their exact recovery commands, then stop. A later backup alone cannot undo earlier repairs.
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
9
 
10
- ## Decide whether diagnosis applies
10
+ ## One command diagnoses and repairs
11
11
 
12
- Diagnose unexpected writes/listing denied inside a `workspace-write` workspace, or reads the signed-in user should plainly have. Root-only failure and uniform failure are both eligible.
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
13
 
14
- Stop and explain expected denials: writes outside the workspace, any write in `read-only`, piped grandchild `spawn EPERM`, or ConstrainedLanguage errors for .NET/COM/reflection. Request an approval for the one call you still need; do not repair ACLs for these cases.
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.
15
17
 
16
- ## Read the report before choosing a repair
17
-
18
- Resolve the script from this skill's resource directory. Run it in the current sandbox first: classification completes under `workspace-write` with a writable `-Out` directory, while `read-only` cannot write one and refuses. When a denial or an unwritable report directory prevents the call, request one escalation of that same call through the normal approval path instead of assuming the diagnosis cannot run; if approval is refused, unavailable to the session, or forbidden by policy, report the path as undiagnosed and stop. Unconfined does not elevate the Windows token. Use one path per invocation; do not repeat `-Path` or pass a comma-separated string to `pwsh -File`.
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
19
 
20
20
  ```powershell
21
- & '<skill-directory>\scripts\diagnose-windows-sandbox-acl.ps1' -Path '<failing-path>' -Out '<report-directory>' -Compact
21
+ & '<skill-directory>\scripts\diagnose-windows-sandbox-acl.ps1' -Path '<failing-path>' -AllowRoot '<authorized-directory>' -Out '<recovery-directory>'
22
22
  exit $LASTEXITCODE
23
23
  ```
24
24
 
25
- Substitute full, quoted paths. Choose a persistent, user-owned `-Out` directory, preferably beside the failing workspace within the authorized tree. Never put reports or backups in the skill resource directory: it is deleted when the skill unloads. `-Compact` saves a unique full JSONL report and prints one `REPORT` summary. Diagnosis changes no ACL or existing content; it only writes this requested report. The summary includes all `inspectedPaths`, findings, decisions, ACL actions, verification, rollback state and `nextAction`. Use the summary to decide; read only specific records from the saved report when evidence is missing, without dumping the whole report or reading the implementation to rediscover these instructions.
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.
26
32
 
27
- Each full record has `kind`, `operation`, `path`, `status`, `reason`, `details`. Unknown observations remain unknown. A deny ACE's presence alone does not establish causation. DSH provisions `S-1-4-*` workspace grants and an Everyone `DeleteSubdirectoriesAndFiles` deny; these are expected confinement entries, not package conflicts or automatically unresolved faults. Coexisting package ACEs may still block the confined child even when provisioning fails first; do not dismiss either finding. `completed` actions only confirm API execution. Check verification and the final summary. Missing summary means unconfirmed completion: inspect the report and recovery artifacts before doing anything else.
33
+ ## Read the output
28
34
 
29
- | Verdict | Next step, only with complete observations |
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 |
30
38
  |---|---|
31
- | `CULPRIT` | Explain the affected paths and individual package allow SIDs; select `-Fix`. |
32
- | `PRECONDITION` | Explain missing effective `WRITE_DAC`/`WRITE_OWNER`; select `-GrantFullControl` if the caller has `WRITE_DAC`. |
33
- | `BOTH` | Grant on the affected object first. Only after verified success may a separate invocation use `-Fix`. Failure ends this sequence. |
34
- | `UNREADABLE`, `INCOMPLETE`, `NOT_THIS_CLASS` | Report observations and stop; do not infer a safe repair. |
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.
35
48
 
36
- ## Run the selected repair
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.
37
50
 
38
- Explain the finding and cost first. `-Fix` removes individual `S-1-15-2-*` allow ACEs at their explicit sources, ancestor first, then verifies inherited entries disappeared; well-known groups ending in 1 or 2 are preserved. This removes those packages' access. `-GrantFullControl` adds a current-user allow ACE; it cannot cancel an explicit deny. Both preserve owner, inheritance and SACL, including Low integrity labels. Low executable labels and their effects outside DSH are outside this repair's scope. Full control supplies `WRITE_DAC` and `WRITE_OWNER`; taking ownership alone does not.
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:
39
52
 
40
- Append `-AllowRoot '<authorized-containing-directory>'` and **exactly one** of `-Fix` or `-GrantFullControl` to the diagnostic command, before `exit`. Keep `-Compact -Out`. Every explicit source must be strictly inside that root; reparse paths and managed application trees (`LocalAppData\Packages`, `ProgramFiles\WindowsApps`) are refused. Never widen the authorized root to reach an ancestor. Missing effective `WRITE_DAC` in the unconfined caller requires stopping and reporting the path and missing rights for permission-policy review. Extracted scripts and recovery copies are user-writable: never supply commands to run them elevated, initiate UAC/`runas`, or run the whole agent elevated.
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.
41
56
 
42
- Follow `nextAction`: `stop` ends repairs; `restore_pending_then_stop` requires the reported recovery commands in their listed order, then stop. Verified rollback does not turn a failed repair into success. Keep full report/backup paths and exact recovery commands for every invocation; never abbreviate executable paths. On recovery failure, stop and report the remaining commands without more repairs.
57
+ ## Never
43
58
 
44
- For `verify_original_confined_operation`, repeat the original failed operation in the original confined context. If it still fails, restore this workflow's successful repairs in reverse order, report the error and stop. Do not start another repair cycle.
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.
45
62
 
46
- Use the user's language for progress and final reporting. Concisely report observations and analyzed paths (including relevant ancestors), actions and reasons, verification, and the next step; link full reports. Label any authorized unconfined fallback as such, not as successful repair. Explain that another tool may recreate a removed package ACE. Never write probe files, edit ACLs by hand, change owners, erase denies, or replace child permissions recursively. Do not provide manual ACL-edit commands for the user either: only bundled repairs and generated recovery commands are supported; unresolved denies require permission-policy review or moving the workspace.
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.
@@ -1,75 +1,66 @@
1
1
  <#
2
2
  .SYNOPSIS
3
- Read-only diagnosis, and whitelisted repair, of Windows ACL objects that the
4
- DSH sandbox cannot open.
3
+ Diagnose the DSH sandbox ACL failures on Windows and repair them in one run.
5
4
 
6
5
  .DESCRIPTION
7
6
  A denial inside the DSH sandbox is worth diagnosing only when it contradicts
8
7
  what the active mode promises: writes inside the workspace, or reads that the
9
- signed-in user should plainly have. This script reports why such an object is
10
- unreadable or unopenable, and repairs package-ACE conflicts or missing rights.
11
-
12
- Diagnosis (the default) never changes ACLs: it reads each requested path
13
- and every ancestor with .NET and native icacls, and prints machine-parseable
14
- lines for the caller to interpret. REPORT JSON records distinguish observed
15
- facts, decisions and their reasons, attempted operations, and verification.
16
- A caught exception still emits the operation history and recovery commands.
17
- Failed repairs restore attempted DACL changes in reverse order and stop.
18
-
19
- Repair (-Fix) removes ONLY allow ACEs whose SID is an AppContainer package SID
20
- (S-1-15-2-*, excluding the well-known groups ending in 1 or 2). Removing them
21
- also removes that package's access. Repair requires
22
- -AllowRoot, refuses any target outside it or reached through a reparse point,
23
- and records a DACL backup with an independent recovery script. The grant checks
24
- effective rights; removal verifies that other icacls lines remain unchanged.
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.
25
34
 
26
35
  The script never creates, deletes, or writes the contents of any file. Its
27
- only content writes are recovery artifacts and explicitly requested reports.
36
+ only writes are the recovery artifacts under -Out and the DACLs it repairs.
28
37
 
29
38
  .PARAMETER Path
30
- One or more failing paths. Each is diagnosed together with its ancestors.
39
+ One or more failing paths. Each is diagnosed and repaired together with its ancestors.
31
40
 
32
41
  .PARAMETER AllowRoot
33
- Required with any mutation. Every modified object must be strictly inside this directory.
42
+ Required. Every modified object must be this directory or strictly inside it.
34
43
 
35
44
  .PARAMETER Out
36
- Required with -Fix, -GrantFullControl or -Compact. Receives recovery artifacts
37
- and, with -Compact, a unique full JSONL report.
38
-
39
- .PARAMETER Compact
40
- Save every REPORT record under -Out and print a decision summary with the
41
- inspected paths, findings, actions, verification and pending recovery commands.
42
-
43
- .PARAMETER Fix
44
- Remove individual package allow ACEs, preserving well-known package groups.
45
- Without a mutation switch, only diagnose.
46
-
47
- .PARAMETER GrantFullControl
48
- Grant the current user full control on the requested path. Full control is what
49
- the documented precondition needs: it carries WRITE_DAC for the DACL and
50
- WRITE_OWNER for the mandatory label DSH writes in the same call, while taking
51
- ownership supplies only WRITE_DAC. The owner is never changed. Mutually
52
- exclusive with -Fix.
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.
53
47
 
54
48
  .PARAMETER Restore
55
- Restore the DACL recovery record for exactly one -Path. Requires -AllowRoot
56
- and WRITE_DAC, preserves owner and SACL, and is exclusive with either repair.
49
+ Restore the DACL recovery record for exactly one -Path instead of repairing.
50
+ Requires -AllowRoot and WRITE_DAC, preserves owner and SACL.
57
51
 
58
52
  .EXAMPLE
59
- pwsh -File diagnose-windows-sandbox-acl.ps1 -Path 'C:\Users\me\.ssh'
53
+ pwsh -File diagnose-windows-sandbox-acl.ps1 -Path '<failing path>' -AllowRoot '<authorized directory>' -Out '<recovery directory>'
60
54
 
61
55
  .EXAMPLE
62
- 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
56
+ pwsh -File diagnose-windows-sandbox-acl.ps1 -Path '<failing path>' -AllowRoot '<authorized directory>' -Restore '<recovery record>'
63
57
  #>
64
58
  [CmdletBinding()]
65
59
  param(
66
60
  [Parameter(Mandatory = $true)][string[]]$Path,
67
61
  [string]$AllowRoot,
68
62
  [string]$Out,
69
- [switch]$Fix,
70
- [switch]$GrantFullControl,
71
- [string]$Restore,
72
- [switch]$Compact
63
+ [string]$Restore
73
64
  )
74
65
 
75
66
  Set-StrictMode -Version Latest
@@ -78,30 +69,34 @@ $ErrorActionPreference = 'Stop'
78
69
  # These two well-known groups name all packages, not one conflicting package.
79
70
  $PACKAGE_SID = '^S-1-15-2-(?![12]$)'
80
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
81
75
 
82
- function Write-Line { param([string]$Text) if (-not $Compact) { Write-Output $Text } }
76
+ function Write-Line { param([string]$Text) Write-Output $Text }
83
77
 
84
78
  # Reports bypass the success pipeline so diagnostics cannot become a function's
85
79
  # return value. Each JSON record occupies one stdout line, including paths/errors.
86
80
  function Write-Report {
87
81
  param([string]$Kind, [string]$Operation, [string]$Target, [string]$Status, [string]$Reason, $Details = @{})
88
82
  $record = [ordered]@{ kind = $Kind; operation = $Operation; path = $Target; status = $Status; reason = $Reason; details = $Details }
89
- $script:reports.Add($record)
90
83
  if ($Kind -eq 'observation' -and $Status -in @('unknown', 'unreadable', 'partial')) { $script:observationFailures++ }
84
+ $script:reports.Add($record)
91
85
  $json = $record | ConvertTo-Json -Depth 12 -Compress
92
- if ($script:reportWriter) {
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) {
93
89
  try { $script:reportWriter.WriteLine($json); $script:reportWriter.Flush() }
94
- catch {
95
- $script:reportWriter.Dispose()
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 { }
96
95
  $script:reportWriter = $null
97
- $script:reportFailed = $true
98
- [Console]::Out.WriteLine('REPORT ' + $json)
99
- throw
100
96
  }
101
97
  }
102
- if (-not $Compact -or $script:reportFailed) { [Console]::Out.WriteLine('REPORT ' + $json) }
98
+ [Console]::Out.WriteLine('REPORT ' + $json)
103
99
  }
104
-
105
100
  function Invoke-ReportedOperation {
106
101
  param([string]$Operation, [string]$Target, [string]$Reason, [string]$Effect, [scriptblock]$Action)
107
102
  $entry = [ordered]@{ id = $script:operations.Count + 1; operation = $Operation; path = $Target; effect = $Effect; status = 'started' }
@@ -257,15 +252,16 @@ function Test-DangerousRoot {
257
252
  }
258
253
 
259
254
  function Test-UnderRoot {
260
- param([string]$FullPath, [string]$Root)
255
+ param([string]$FullPath, [string]$Root, [switch]$AllowEqual)
261
256
  $r = Get-NormalizedPath $Root
262
257
  $c = Get-NormalizedPath $FullPath
258
+ if ($AllowEqual -and $c -ieq $r) { return $true }
263
259
  return $c -ine $r -and $c.StartsWith($r.TrimEnd('\') + '\', [System.StringComparison]::OrdinalIgnoreCase)
264
260
  }
265
261
 
266
262
  function Get-RepairRefusal {
267
- param([string]$FullPath, [string]$Root)
268
- if (-not (Test-UnderRoot -FullPath $FullPath -Root $Root)) { return "$FullPath is outside -AllowRoot" }
263
+ param([string]$FullPath, [string]$Root, [switch]$AllowEqual)
264
+ if (-not (Test-UnderRoot -FullPath $FullPath -Root $Root -AllowEqual:$AllowEqual)) { return "$FullPath is outside -AllowRoot" }
269
265
  $danger = Test-DangerousRoot -FullPath $FullPath
270
266
  if ($danger) { return "$FullPath is a $danger" }
271
267
  # Checking every component also covers a junction used as AllowRoot itself.
@@ -278,6 +274,68 @@ function Get-RepairRefusal {
278
274
  return $null
279
275
  }
280
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
+
281
339
  function Save-AclBackup {
282
340
  param([string]$FullPath, [string]$Directory, [string]$Root)
283
341
  $fullDirectory = [System.IO.Path]::GetFullPath($Directory)
@@ -286,31 +344,32 @@ function Save-AclBackup {
286
344
  New-Item -ItemType Directory -Path $fullDirectory | Out-Null
287
345
  }
288
346
  }
289
- $backup = Join-Path $fullDirectory ('acl-backup-{0}.txt' -f ([guid]::NewGuid().ToString('N')))
290
- $record = $backup + '.json'
347
+ $record = Join-Path $fullDirectory ('acl-backup-{0}.json' -f ([guid]::NewGuid().ToString('N')))
291
348
  # The skill's extracted directory expires with its registration. Recovery must
292
- # remain runnable from the backup directory after that registration is gone.
293
- $restoreScript = $backup + '.ps1'
349
+ # remain runnable from the recovery directory after that registration is gone.
350
+ $restoreScript = $record + '.ps1'
294
351
  $command = "pwsh -NoProfile -File '{0}' -Path '{1}' -AllowRoot '{2}' -Restore '{3}'" -f
295
352
  $restoreScript.Replace("'", "''"), $FullPath.Replace("'", "''"), ([System.IO.Path]::GetFullPath($Root)).Replace("'", "''"), $record.Replace("'", "''")
296
- Write-Report decision backup $FullPath selected 'All recovery files must be written successfully before attempting the ACL change.' @{ files = @($backup, $record, $restoreScript, ($backup + '.rollback.txt')) }
353
+ Write-Report decision backup $FullPath selected 'The recovery record and an independent recovery script are written before the ACL change.' @{ files = @($record, $restoreScript) }
297
354
  Invoke-ReportedOperation backup $FullPath 'Save the original DACL and an independent recovery command before changing permissions.' files {
298
- $output = @(icacls $FullPath /save $backup 2>&1)
299
- if ($LASTEXITCODE -ne 0) { throw "Could not back up the DACL (icacls exit $LASTEXITCODE): $($output -join "`n")" }
300
- @{ Path = $FullPath; Dacl = (Get-Acl -LiteralPath $FullPath).GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access) } |
301
- ConvertTo-Json | Set-Content -LiteralPath $record -Encoding utf8
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
302
363
  Copy-Item -LiteralPath $PSCommandPath -Destination $restoreScript
303
- $command | Set-Content -LiteralPath ($backup + '.rollback.txt')
304
364
  }
305
- $script:recoveries.Add(@{ path = $FullPath; backup = $backup; record = $record; script = $restoreScript; command = $command; attempted = $false; restored = $false })
365
+ $script:recoveries.Add(@{ path = $FullPath; record = $record; script = $restoreScript; command = $command; attempted = $false; restored = $false })
306
366
  Write-Report observation recovery $FullPath available 'These files can restore the saved DACL; no rollback has been executed.' $script:recoveries[-1]
307
- Write-Line ('BACKUP {0} -> {1}' -f $FullPath, $backup)
367
+ Write-Line ('BACKUP {0} -> {1}' -f $FullPath, $record)
308
368
  Write-Line ('ROLLBACK {0}' -f $command)
309
369
  }
310
-
311
370
  function Restore-SavedDacl {
312
371
  param([string]$FullPath, [string]$Record, [string]$Root)
313
- $refusal = Get-RepairRefusal -FullPath $FullPath -Root $Root
372
+ $refusal = Get-RepairRefusal -FullPath $FullPath -Root $Root -AllowEqual
314
373
  if ($refusal) { throw [System.ArgumentException]::new("RESTORE_REFUSED $refusal") }
315
374
  $saved = Invoke-ReportedOperation read_recovery $Record 'Read the recovery record and verify it belongs to the requested path before restoring its DACL.' none {
316
375
  Get-Content -LiteralPath $Record -Raw | ConvertFrom-Json
@@ -322,46 +381,25 @@ function Restore-SavedDacl {
322
381
  Invoke-ReportedOperation restore_dacl $FullPath 'Restore the requested backup DACL and inheritance protection, preserving owner and SACL.' acl {
323
382
  [Dsh.TokenInfo]::SetDacl($FullPath, $saved.Dacl)
324
383
  }
325
- $actual = Invoke-ReportedOperation read_restored_dacl $FullPath 'Read the restored DACL to compare it with the saved record.' none {
326
- (Get-Acl -LiteralPath $FullPath).GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access)
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
327
386
  }
328
- Write-Report verification restore $FullPath $(if ($actual -eq $saved.Dacl) { 'verified' } else { 'failed' }) 'Compare the observed DACL with the recovery record after writing it.' @{ expectedDacl = $saved.Dacl; actualDacl = $actual }
329
- if ($actual -ne $saved.Dacl) { throw 'RESTORE_FAILED the restored DACL differs from the backup' }
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' }
330
397
  $script:restored++
331
398
  Write-Line ('RESTORED {0}' -f $FullPath)
332
399
  }
333
400
 
334
- function Write-CompactSummary {
335
- param([string]$Status, [string]$NextAction, [array]$RollbackCommands)
336
- $byPath = [ordered]@{}
337
- foreach ($record in $script:reports) { if ($record.operation -eq 'inspect_acl') { $byPath[$record.path] = $record } }
338
- $observations = @($byPath.Values)
339
- $classifications = @($script:reports | Where-Object { $_.operation -eq 'classify' })
340
- $findings = @($observations | Where-Object {
341
- $_.path -in $script:requestedPaths -or $_.status -ne 'read' -or
342
- @($_.details['aces'] | Where-Object { $_ -and $_.sid -match $PACKAGE_SID -and $_.type -eq 'Allow' }).Count -gt 0
343
- } | ForEach-Object {
344
- @{ path = $_.path; status = $_.status; writeDac = $_.details['writeDac']; writeOwner = $_.details['writeOwner']
345
- packageAllowSids = @($_.details['aces'] | Where-Object { $_ -and $_.sid -match $PACKAGE_SID -and $_.type -eq 'Allow' } | ForEach-Object { $_.sid })
346
- denies = @($_.details['aces'] | Where-Object { $_ -and $_.type -eq 'Deny' }); errors = @($_.details['errors']; $_.details['error']) }
347
- })
348
- $details = [ordered]@{
349
- report = $script:reportPath; exitCode = $script:exitCode; nextAction = $NextAction
350
- inspectedPaths = @($observations | ForEach-Object { $_.path } | Select-Object -Unique)
351
- findings = $findings
352
- decisions = @($classifications | ForEach-Object { @{ path = $_.path; verdict = $_.status; reason = $_.reason; packageObjects = $_.details.packageObjects } })
353
- actions = @($script:reports | Where-Object { $_.kind -eq 'action' -and $_.details['effect'] -eq 'acl' -and $_.status -ne 'started' } | ForEach-Object { @{ operation = $_.operation; path = $_.path; status = $_.status; reason = $_.reason } })
354
- verification = @($script:reports | Where-Object { $_.kind -eq 'verification' } | ForEach-Object { @{ operation = $_.operation; path = $_.path; status = $_.status } })
355
- rollback = $script:rollbackStatus; rollbackCommands = $RollbackCommands
356
- errors = @($script:reports | Where-Object { $_.kind -eq 'error' -or ($_.kind -eq 'decision' -and $_.status -eq 'refused') } | ForEach-Object { @{ path = $_.path; reason = $_.reason; details = $_.details } })
357
- }
358
- $record = @{ kind = 'summary'; operation = $script:mode; path = $script:currentPath; status = $Status
359
- reason = 'Full observations, actions and reasons are in the report. Follow nextAction; a failed repair must not be followed by another repair.'; details = $details }
360
- [Console]::Out.WriteLine('REPORT ' + ($record | ConvertTo-Json -Depth 12 -Compress))
361
- }
362
-
363
401
  function Get-ObjectFacts {
364
- param([string]$FullPath, [string]$MeSid)
402
+ param([string]$FullPath, [string]$MeSid, [switch]$CompactRecord)
365
403
  $facts = [ordered]@{
366
404
  Object = $FullPath
367
405
  Readable = $false
@@ -429,7 +467,10 @@ function Get-ObjectFacts {
429
467
  }
430
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.' @{
431
469
  owner = $facts.Owner; writeDac = $facts.HasWriteDac; writeOwner = $facts.HasWriteOwner
432
- aces = $facts.Aces; nativeListing = @($facts.AclLines | ForEach-Object { $_.Trim() } | Where-Object { $_ }); lowLabel = $facts.LowLabel; errors = $facts.Errors
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
433
474
  }
434
475
  return $facts
435
476
  }
@@ -452,9 +493,8 @@ function Get-Ancestors {
452
493
  $operations = [System.Collections.Generic.List[object]]::new()
453
494
  $recoveries = [System.Collections.Generic.List[object]]::new()
454
495
  $reports = [System.Collections.Generic.List[object]]::new()
455
- $reportWriter = $null
456
496
  $reportPath = $null
457
- $reportFailed = $false
497
+ $reportWriter = $null
458
498
  $requestedPaths = @()
459
499
  $rollbackStatus = 'not-needed'
460
500
  $fixed = 0
@@ -463,17 +503,20 @@ $refused = 0
463
503
  $restored = 0
464
504
  $exitCode = 0
465
505
  $observationFailures = 0
506
+ $scanTruncated = $false
466
507
  $currentPath = ''
467
- $mode = if ($Restore) { 'restore' } elseif ($GrantFullControl) { 'grant' } elseif ($Fix) { 'fix' } else { 'diagnose' }
508
+ $mode = if ($Restore) { 'restore' } else { 'repair' }
468
509
 
469
510
  try {
470
511
  $requestedPaths = @($Path | ForEach-Object { [System.IO.Path]::GetFullPath($_) })
471
- Write-Report invocation $mode '' started 'Inspect the requested paths before deciding whether the requested operation can run.' @{
472
- paths = $Path; allowRoot = $AllowRoot; outputDirectory = $Out; recoveryRecord = $Restore; fix = $Fix.IsPresent; grantFullControl = $GrantFullControl.IsPresent
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
473
514
  }
474
- if ($Compact) {
475
- if (-not $Out) { throw [System.ArgumentException]::new('-Compact requires -Out for the complete report') }
476
- Invoke-ReportedOperation prepare_report $Out 'Create the requested report directory and a new JSONL report; existing reports are never overwritten.' files {
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 {
477
520
  $directory = [System.IO.Path]::GetFullPath($Out)
478
521
  [System.IO.Directory]::CreateDirectory($directory) | Out-Null
479
522
  $script:reportPath = Join-Path $directory ('acl-report-{0}.jsonl' -f [guid]::NewGuid().ToString('N'))
@@ -488,20 +531,6 @@ try {
488
531
  $integrity = Get-IntegritySid
489
532
  Write-Line ('CALLER SID={0} INTEGRITY={1}' -f $meSid, $integrity)
490
533
  Write-Report observation caller '' read 'The caller token determines effective access; unconfined execution does not imply elevation.' @{ sid = $meSid; integrity = $integrity }
491
-
492
- if (@(@($Fix.IsPresent, $GrantFullControl.IsPresent, [bool]$Restore) | Where-Object { $_ }).Count -gt 1) {
493
- throw [System.ArgumentException]::new('-Fix, -GrantFullControl and -Restore are separate operations; run one at a time')
494
- }
495
- if ($Fix -or $GrantFullControl -or $Restore) {
496
- $requiredArguments = if ($Restore) { @('AllowRoot') } else { @('AllowRoot', 'Out') }
497
- foreach ($required in $requiredArguments) {
498
- if (-not (Get-Variable -Name $required -ValueOnly)) {
499
- throw [System.ArgumentException]::new("The requested repair requires -$required")
500
- }
501
- }
502
- if ($Restore -and $Path.Count -ne 1) { throw [System.ArgumentException]::new('-Restore requires exactly one -Path') }
503
- }
504
-
505
534
  :paths foreach ($requested in $Path) {
506
535
  $currentPath = $requested
507
536
  $full = Get-NormalizedPath $requested
@@ -511,23 +540,27 @@ try {
511
540
  Write-Line ' MISSING'
512
541
  Write-Line 'VERDICT=NOT_THIS_CLASS'
513
542
  Write-Decision $mode $full skipped 'The requested path does not exist; no ACL was read or changed for it.'
514
- if ($mode -ne 'diagnose') { $refused++; break paths }
515
- continue
543
+ $refused++
544
+ break paths
516
545
  }
517
546
 
547
+ $factsByPath = @{}
548
+ $inspectOrder = @()
518
549
  $packageTargets = @()
519
550
  $targetFacts = $null
520
551
  foreach ($ancestor in (Get-Ancestors -FullPath $full)) {
521
552
  if (-not (Test-Path -LiteralPath $ancestor)) { continue }
522
- $f = Get-ObjectFacts -FullPath $ancestor -MeSid $meSid
523
- if ($ancestor -eq $full) { $targetFacts = $f }
524
- Write-Line (' OBJECT={0} READABLE={1}{2}' -f $f.Object, $f.Readable, $(if ($f.Readable) { '' } else { ' ERROR=' + $f.Error }))
525
- if ($f.Readable) {
526
- 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)
527
- foreach ($ace in $f.PackageAces) { Write-Line (' PACKAGE_ACE {0}' -f $ace) }
528
- foreach ($sid in ($f.OtherAppContainerSids | Sort-Object -Unique)) { Write-Line (' OTHER_S1_15 SID={0} (reported only)' -f $sid) }
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) }
529
562
  }
530
- if ($f.PackageAces.Count -gt 0) { $packageTargets += $f }
563
+ if ($facts.PackageAces.Count -gt 0) { $packageTargets += $facts.Object }
531
564
  }
532
565
 
533
566
  # The precondition is judged on the requested path itself. Ancestors above the
@@ -538,11 +571,32 @@ try {
538
571
  $needsPrecondition = (-not $unreadable) -and
539
572
  ((-not $targetFacts.HasWriteDac) -or (-not $targetFacts.HasWriteOwner))
540
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
+
541
595
  if ($targetFacts.Errors.Count -gt 0) {
542
596
  $verdict = 'INCOMPLETE'
543
597
  $reason = 'Some observations failed; missing evidence is not evidence that a right is absent or a repair is safe.'
544
598
  } elseif ($packageTargets.Count -gt 0) {
545
- $blocked = @($packageTargets | Where-Object { -not $_.HasWriteDac })
599
+ $blocked = @($packageTargets | Where-Object { -not $factsByPath[$_].HasWriteDac })
546
600
  $verdict = if ($needsPrecondition -or $blocked.Count -gt 0) { 'BOTH' } else { 'CULPRIT' }
547
601
  $reason = 'Package allow ACEs were observed. Required access checks determine whether the caller can perform the requested repair; other causes remain possible.'
548
602
  } elseif ($unreadable) {
@@ -556,133 +610,138 @@ try {
556
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.'
557
611
  }
558
612
  Write-Line ('VERDICT={0}' -f $verdict)
559
- Write-Report decision classify $full $verdict $reason @{ writeDac = $targetFacts.HasWriteDac; writeOwner = $targetFacts.HasWriteOwner; packageObjects = @($packageTargets | ForEach-Object { $_.Object }) }
613
+ Write-Report decision classify $full $verdict $reason @{ writeDac = $targetFacts.HasWriteDac; writeOwner = $targetFacts.HasWriteOwner; packageObjects = @($packageTargets) }
560
614
 
561
- if ($mode -eq 'diagnose') {
562
- Write-Decision diagnose $full skipped 'No mutation mode was requested; all reported ACLs were left unchanged.'
615
+ if ($Restore) {
616
+ Restore-SavedDacl -FullPath $full -Record $Restore -Root $AllowRoot
563
617
  continue
564
618
  }
565
619
  if ($targetFacts.Errors.Count -gt 0) {
566
620
  Write-Decision $mode $full refused 'Required observations are incomplete; no repair was attempted for this path.'
567
- $refused++; break paths
621
+ $refused++
622
+ break paths
568
623
  }
569
624
 
570
- if ($Restore) {
571
- Restore-SavedDacl -FullPath $full -Record $Restore -Root $AllowRoot
572
- continue
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
+ }
573
635
  }
574
636
 
575
- if ($GrantFullControl) {
576
- if ($unreadable) { Write-Line 'GRANT_REFUSED the object could not be read; the grant needs an unconfined caller'; Write-Decision grant $full refused 'The ACL is unreadable, so a preserving grant cannot be constructed.'; $refused++; break paths }
577
- $refusal = Get-RepairRefusal -FullPath $full -Root $AllowRoot
578
- if ($refusal) { Write-Line ('GRANT_REFUSED {0}' -f $refusal); Write-Decision grant $full refused $refusal; $refused++; break paths }
579
- if ($targetFacts.HasWriteDac -and $targetFacts.HasWriteOwner) { Write-Line ('GRANT_SKIPPED {0} already carries WRITE_DAC and WRITE_OWNER' -f $full); Write-Decision grant $full skipped 'Effective WRITE_DAC and WRITE_OWNER are already available; no grant or backup is needed.'; continue }
580
- if (-not $targetFacts.HasWriteDac) { Write-Line 'GRANT_REFUSED the caller lacks WRITE_DAC; stop for permission-policy review'; Write-Decision grant $full refused 'Effective WRITE_DAC is absent; this caller cannot change the DACL. Stop for permission-policy review; do not elevate a writable script copy.'; $refused++; break paths }
581
- Save-AclBackup -FullPath $full -Directory $Out -Root $AllowRoot
582
- # AddAccessRule preserves explicit denies; icacls /grant can remove a deny
583
- # for the same principal. Write only the DACL, leaving owner and SACL intact.
584
- $grantAcl = Get-Acl -LiteralPath $full
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
585
663
  $grantAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
586
664
  [System.Security.Principal.SecurityIdentifier]::new($meSid),
587
665
  [System.Security.AccessControl.FileSystemRights]::FullControl,
588
666
  [System.Security.AccessControl.AccessControlType]::Allow))
589
- Invoke-ReportedOperation grant_dacl $full "WRITE_OWNER is missing and WRITE_DAC is available; add a FullControl allow ACE for $meSid while preserving deny ACEs, owner and SACL." acl {
590
- [Dsh.TokenInfo]::SetDacl($full, $grantAcl.GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access))
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))
591
669
  }
592
- $after = Get-ObjectFacts -FullPath $full -MeSid $meSid
593
- $verified = $after.Readable -and $after.Errors.Count -eq 0 -and $after.HasWriteDac -and $after.HasWriteOwner
594
- Write-Report verification grant $full $(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.' @{
595
- before = @{ writeDac = $targetFacts.HasWriteDac; writeOwner = $targetFacts.HasWriteOwner }
596
- after = @{ writeDac = $after.HasWriteDac; writeOwner = $after.HasWriteOwner }
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 }
597
676
  recovery = $recoveries[-1].command
598
677
  }
599
678
  if ($verified) {
600
- Write-Line ('GRANTED {0} SID={1}' -f $full, $meSid); $granted++
679
+ Write-Line ('GRANTED {0} SID={1}' -f $object, $meSid); $granted++
601
680
  } else {
602
- Write-Line ('GRANT_FAILED {0}; effective WRITE_DAC and WRITE_OWNER were not both confirmed; restore this invocation and stop' -f $full)
681
+ Write-Line ('GRANT_FAILED {0}; effective WRITE_DAC and WRITE_OWNER were not both confirmed; restore this invocation and stop' -f $object)
603
682
  $refused++
604
683
  break paths
605
684
  }
606
- continue
607
685
  }
608
686
 
609
- if (-not $Fix) { continue }
610
-
611
- if ($unreadable) {
612
- Write-Line 'FIX_REFUSED the object could not be read; repair needs an unconfined caller'
613
- Write-Decision fix $full refused 'The requested ACL could not be read; no package removal was attempted.'
614
- $refused++
615
- break paths
616
- }
617
- if ($packageTargets.Count -eq 0) { Write-Decision fix $full skipped 'No package allow ACE was observed on the inspected objects; no ACL change or backup is needed.' }
618
687
  $sources = @($packageTargets | Where-Object {
619
- @($_.Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited }).Count -gt 0
688
+ @($factsByPath[$_].Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited }).Count -gt 0
620
689
  })
621
- [array]::Reverse($sources)
622
- foreach ($target in (@($sources) + @($packageTargets))) {
623
- $refusal = Get-RepairRefusal -FullPath $target.Object -Root $AllowRoot
624
- if ($refusal) { Write-Line ('FIX_REFUSED {0}' -f $refusal); Write-Decision fix $target.Object refused $refusal; $refused++; break paths }
625
- if ($target.Errors.Count -gt 0) { Write-Decision fix $target.Object refused 'The ACL observations are incomplete; collateral changes could not be verified.'; $refused++; break paths }
626
- }
627
- if ($packageTargets.Count -gt 0 -and $needsPrecondition) {
628
- Write-Decision fix $full refused 'Package removal requires verified WRITE_DAC and WRITE_OWNER on the requested path. A failed grant must not be followed by -Fix.'
629
- $refused++; break paths
630
- }
631
- foreach ($target in $sources) {
632
- if (-not $target.HasWriteDac) {
633
- Write-Line ('FIX_REFUSED {0} needs WRITE_DAC; stop for permission-policy review' -f $target.Object)
634
- Write-Decision fix $target.Object refused 'Effective WRITE_DAC is absent; removing a package allow ACE requires that right.'
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.'
635
696
  $refused++
636
697
  break paths
637
698
  }
638
699
  }
639
- Write-Report decision fix_sources $full selected 'Remove explicit package allow ACEs from their sources, ancestor first. Inherited entries are verified after source changes; inheritance stays enabled.' @{
640
- sources = @($sources | ForEach-Object { $_.Object }); affectedPaths = @($packageTargets | ForEach-Object { $_.Object })
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
+ }
641
704
  }
642
- foreach ($target in $sources) {
643
- Save-AclBackup -FullPath $target.Object -Directory $Out -Root $AllowRoot
644
- $sids = @($target.Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited } | ForEach-Object { $_.sid } | Sort-Object -Unique)
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)
645
708
  foreach ($sid in $sids) {
646
- $repairPath = $target.Object
647
- Invoke-ReportedOperation remove_package_allow $repairPath "Remove the observed package allow ACE for $sid as requested by -Fix; preserve deny ACEs and all other principals." acl {
648
- $output = @(icacls $repairPath /remove:g "*$sid" 2>&1)
649
- if ($LASTEXITCODE -ne 0) { throw "icacls removal exit ${LASTEXITCODE}: $($output -join "`n")" }
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)" }
650
712
  }
651
713
  }
652
714
  }
653
- foreach ($target in $packageTargets) {
654
- $before = $target.AclLines
655
- $after = Get-ObjectFacts -FullPath $target.Object -MeSid $meSid
656
- # icacls prefixes only the first ACE with the path, so compare entries
657
- # without that prefix when checking for collateral changes.
658
- $keepBefore = @($before | ForEach-Object {
659
- $line = $_.Trim()
660
- if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() }
661
- $line
662
- } | Where-Object { $_ -notmatch $PACKAGE_SID })
663
- $keepAfter = @($after.AclLines | ForEach-Object {
664
- $line = $_.Trim()
665
- if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() }
666
- $line
667
- } | Where-Object { $_ -notmatch $PACKAGE_SID })
668
- # A package deny can share the removed allow's SID. Preserve it explicitly
669
- # even though native listing comparison omits lines naming package SIDs.
670
- $expectedAces = @($target.Aces | Where-Object { -not ($_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID) })
671
- $collateral = (($keepBefore -join "`n") -eq ($keepAfter -join "`n")) -and
672
- (($expectedAces | ConvertTo-Json -Compress) -eq ($after.Aces | ConvertTo-Json -Compress))
673
- $verified = $after.Readable -and $after.Errors.Count -eq 0 -and $after.PackageAces.Count -eq 0 -and $collateral
674
- Write-Report verification fix $target.Object $(if ($verified) { 'verified' } else { 'failed' }) 'The removal command completed; verify that package allow ACEs disappeared and other ACL listing entries remained unchanged. On failure, restore this invocation and stop.' @{
675
- remainingPackageAces = $after.PackageAces; otherEntriesUnchanged = $collateral
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
676
729
  }
677
730
  if ($verified) {
678
- $explicitSids = @($target.Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited } | ForEach-Object { $_.sid } | Sort-Object -Unique)
679
- foreach ($sid in $explicitSids) { Write-Line ('FIXED {0} SID={1}' -f $target.Object, $sid); $fixed++ }
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
+ }
680
735
  } else {
681
- Write-Line ('FIX_FAILED {0} collateral_change={1}' -f $target.Object, (-not $collateral))
736
+ Write-Line ('FIX_FAILED {0} collateral_change={1}' -f $packagePath, (-not $collateral))
682
737
  $refused++
683
738
  break paths
684
739
  }
685
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
+ }
686
745
  }
687
746
  } catch {
688
747
  $exitCode = if ($_.Exception -is [System.ArgumentException]) { 2 } else { 1 }
@@ -707,15 +766,33 @@ try {
707
766
  }
708
767
  $pending = @($recoveries | Where-Object { $_.attempted -and -not $_.restored } | ForEach-Object { $_.command })
709
768
  [array]::Reverse($pending)
710
- $nextAction = if ($rollbackStatus -eq 'failed') { 'restore_pending_then_stop' } elseif ($exitCode -ne 0 -or $observationFailures -gt 0 -or $Restore) { 'stop' } elseif ($mode -eq 'diagnose') { 'review_findings' } else { 'verify_original_confined_operation' }
711
- Write-Line ('SUMMARY FIXED={0} GRANTED={1} REFUSED={2} RESTORED={3}' -f $fixed, $granted, $refused, $restored)
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
+ }
712
785
  $status = if ($exitCode -ne 0) { 'failed' } elseif ($observationFailures -gt 0) { 'partial' } else { 'completed' }
713
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.' @{
714
787
  exitCode = $exitCode; fixed = $fixed; granted = $granted; refused = $refused; restored = $restored
715
788
  operations = @($operations.ToArray()); recoveries = @($recoveries.ToArray()); automaticRollback = $true; observationFailures = $observationFailures
716
- rollback = $rollbackStatus; rollbackCommands = $pending; nextAction = $nextAction; report = $reportPath
789
+ rollback = $rollbackStatus; rollbackCommands = $pending; nextAction = $nextAction; scanTruncated = $scanTruncated; report = $reportPath
717
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)
718
796
  if ($reportWriter) { $reportWriter.Dispose() }
719
- if ($Compact) { Write-CompactSummary -Status $status -NextAction $nextAction -RollbackCommands $pending }
720
797
  }
721
798
  exit $exitCode
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.2.0-rc.1",
4
+ "version": "0.2.0-rc.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -35,23 +35,23 @@
35
35
  "license": "MIT",
36
36
  "peerDependencies": {
37
37
  "@deepseek-ai/cordis": "~4.0.4",
38
- "@deepseek-ai/dsh-subprocess": "0.2.0-rc.1",
39
- "@deepseek-ai/dsh-skill": "0.2.0-rc.1"
38
+ "@deepseek-ai/dsh-skill": "0.2.0-rc.2",
39
+ "@deepseek-ai/dsh-subprocess": "0.2.0-rc.2"
40
40
  },
41
41
  "dependencies": {
42
42
  "koffi": "3.1.1",
43
43
  "yaml": "^2.4.2",
44
- "@deepseek-ai/dsh-lazy-require": "0.2.0-rc.1",
45
- "@deepseek-ai/dsh-win32-process": "0.2.0-rc.1"
44
+ "@deepseek-ai/dsh-lazy-require": "0.2.0-rc.2",
45
+ "@deepseek-ai/dsh-win32-process": "0.2.0-rc.2"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@deepseek-ai/cordis-plugin-include": "~1.0.9",
49
- "@deepseek-ai/dsh-pwsh-local": "0.2.0-rc.1",
50
49
  "@deepseek-ai/cordis-plugin-loader": "~1.0.5",
51
- "@deepseek-ai/dsh-sandbox-local": "0.2.0-rc.1",
52
- "@deepseek-ai/dsh-skill": "0.2.0-rc.1",
53
- "@deepseek-ai/dsh-subprocess": "0.2.0-rc.1",
54
- "@deepseek-ai/dsh-subprocess-local": "0.2.0-rc.1",
55
- "@deepseek-ai/cordis": "~4.0.4"
50
+ "@deepseek-ai/dsh-pwsh-local": "0.2.0-rc.2",
51
+ "@deepseek-ai/dsh-sandbox-local": "0.2.0-rc.2",
52
+ "@deepseek-ai/cordis": "~4.0.4",
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"
56
56
  }
57
57
  }