fix(windows): diagnose and repair ACL failures in one run

The script made the caller choose a mode, so one sandbox failure cost two
approvals: one to diagnose and one to repair. It now has no modes. A single run
adds the signed-in user's full-control allow ACE to the directories on the chain
that lack effective WRITE_DAC or WRITE_OWNER, removes explicit package allow ACEs
at their sources, ancestor first, and verifies every change by re-reading it.

A directory the caller cannot provision also has its subtree checked for explicit
package allow ACEs in that same run: the sandbox reports its provisioning failure
on the workspace root while the conflicting entry often sits deeper, and the walk
is bounded and reports truncation. The authorized root itself may be granted and
restored, so a workspace root can repair and roll itself back.

Recovery artifacts shrink to one record and one script per modified object; the
unused icacls save file and the duplicated command file are gone. The skill, its
README paragraphs and the script tests follow the single command: the former
-Fix, -GrantFullControl and -Compact switches are rejected.
This commit is contained in:
Huanqi Cao committed 2026-09-29 15:29:51 +08:00
1 parent 4f96bc18df
commit ab0195f802
9 files changed
+893 -911

No files matched your search

@@ -27,8 +27,8 @@
en: 289e65bddf20dd79
zh: 7ff72ed64c4b4d1c
/deepseek-ai-dsh-sandbox-windows-acl/use-this-package/failures-and-recovery:
en: 47c32eff504fcd87
zh: 83c13c06ccb2e07a
en: 3d4cf972fd9f3f95
zh: 82738597ba1f85b6
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation:
en: c2cde86c85c2c619
zh: a29a114e591f9d3f
@@ -77,11 +77,11 @@ Temp isolation is per live session/workspace pair: sessions sharing a workspace
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.
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.
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.
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.
Every run prints `REPORT` JSON records of observations, actions, reasons and verification, then a summary with all inspected paths, findings, actions, recovery commands and `nextAction`; unknown observations remain unknown. `-Out <directory>` receives two files per modified object: the DACL that object owned before the 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.
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.
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.
-----
@@ -79,11 +79,11 @@ rmSync(tempDir, { recursive: true, force: true })
此后端无法解释的拒绝交给 `assets/` 中随包发布的 `diagnose-windows-sandbox-acl` 技能诊断。当 `dsh-sandbox-local` 使用内置 Windows runner 且技能注册表可用时,`registerAclDiagnosisSkill` 会注册它。注册时提取供外部 PowerShell 使用的资源,ASAR 和 SEA 部署也适用;fiber 释放时注销提供者并删除副本。缺少资源会导致注册失败。
诊断不修改 ACL 或已有内容。`-Fix` 从祖先开始移除显式包允许 ACE,并验证其继承副本已消失;请求路径需要有效的 `WRITE_DAC` 和 `WRITE_OWNER`。`-GrantFullControl` 补充调用者缺少的权限。修复要求 `WRITE_DAC`,保留拒绝条目、所有者、继承和 SACL,并备份每个修改的 DACL。目标必须严格位于 `-AllowRoot` 内;重解析路径和受管理的应用目录会被拒绝。修复失败时按相反顺序恢复尝试修改的内容,并以非零状态退出。修复选择、原始操作验证及恢复流程见[内置技能](assets/diagnose-windows-sandbox-acl/SKILL.md)。
一次运行同时完成诊断与修复。脚本没有模式开关:它读取请求路径及每一级祖先,为链路上缺少有效 `WRITE_DAC` 或 `WRITE_OWNER` 的目录补上当前用户的完全控制允许 ACE,并从祖先开始在其来源移除显式包允许 ACE,每处改动都通过重新读取来验证。修复保留拒绝条目、所有者、继承和 SACL,并备份每个被修改的 DACL。仅当对象就是 `-AllowRoot` 或严格位于其内部时才会被修改,因此工作区根目录可以自我修复;重解析路径和受管理的应用目录会被拒绝,修复需要有效的 `WRITE_DAC`。修复失败时按相反顺序恢复尝试修改的内容,并以非零状态退出。单条命令、原始操作验证及恢复流程见[内置技能](assets/diagnose-windows-sandbox-acl/SKILL.md)。
每次执行都会输出包含观察、操作、原因和验证结果的 `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) 说明资源归属和回滚限制。
每次执行都会打印包含观察、操作、原因和验证结果的 `REPORT` JSON 记录,随后给出包含全部分析路径、发现、操作、恢复命令和 `nextAction` 的摘要;未知观察保持未知。`-Out <directory>` 为每个被修改对象写入两个文件:该对象改动前拥有的 DACL,以及用于恢复它的独立脚本。操作完成记录 API 执行情况,验证记录实际观察到的结果。[恢复决策](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md#acl-diagnosis-and-recovery) 说明资源归属和回滚限制。
检查会从请求对象逐级访问祖先,直到文件系统根目录。每条 ACL 观察的 `path` 标识实际检查的对象;分类结果的 `details.packageObjects` 列出包含包允许 ACE 的对象,但不能据此确认原始失败的原因。[父目录包 SID 示例](tests/expected/parent-package-report.jsonl) 保留测试目录路径并展示选定字段,其中 `fixturePackageAces` 只从 `aces` 提取合成测试 SID,省略机器自带的 ACE 和祖先目录。[授权失败示例](tests/expected/denied-grant-report.txt) 保留操作路径和原因。
检查会从请求对象逐级访问祖先,直到文件系统根目录。每条 ACL 观察的 `path` 标识实际检查的对象;分类结果的 `details.packageObjects` 列出包含包允许 ACE 的对象,但不能据此确认原始失败的原因。每次包 ACE 移除都按对象验证:把记录下来的自身 ACE 与重新读取的 DACL 比对,因此与被移除允许项共用 SID 的拒绝条目仍会出现在验证详情中。
-----
@@ -1,46 +1,69 @@
---
name: diagnose-windows-sandbox-acl
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.'
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.'
---
# Diagnose Windows sandbox ACL failures
**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 repair that verified is not reverted because the original operation failed for a further reason: continue from the new observations instead.
**Write every approval request in plain words, in the user's language.** The approval prompt is the only thing the user reads before widening access, so it must stand alone: which folder the script touches, that it adds the signed-in user's own full-control entry where a required right is missing and removes foreign package entries, that file contents and owners are not changed, and that each change can be undone with the recovery command the script prints. Keep error codes, `WRITE_DAC`/`WRITE_OWNER`, `S-1-15-2-*` SIDs, `icacls`, verdict names and switches out of the request — they belong in the report you write afterwards. Ask once, for one command.
## Decide whether diagnosis applies
## One command diagnoses and repairs
Diagnose unexpected denial of workspace writes, listing, or reads the signed-in user should plainly have. Root-only failure and uniform failure are both eligible.
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:
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.
- 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;
- 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;
- when the requested directory is one the sandbox cannot provision — the state its provisioning error reports on the workspace root — explicit package allow ACEs anywhere under it are collected in the same run, so a conflicting entry deeper in the tree needs no second request. That walk is bounded and reports `truncated`; if it was truncated, pass the still-failing deeper path once more.
## Read the report before choosing a repair
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`; `read-only` refuses one. 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 it cannot run, and let that one call both diagnose and repair: run the script with `-Compact`, then re-run it with `-GrantFullControl` and, when the verdict names package ACEs, `-Fix`. The script skips a grant it does not need and refuses an unsafe one, so one approval covers both; 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`.
Every change is backed up first and 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.
```powershell
& '<skill-directory>\scripts\diagnose-windows-sandbox-acl.ps1' -Path '<failing-path>' -Out '<report-directory>' -Compact
& '<skill-directory>\scripts\diagnose-windows-sandbox-acl.ps1' -Path '<failing-path>' -AllowRoot '<authorized-directory>' -Out '<recovery-directory>'
exit $LASTEXITCODE
```
Substitute full, quoted paths. Choose a persistent, user-owned `-Out` directory, preferably beside the failing workspace within the authorized tree. Never use the skill resource directory: it is deleted when the skill unloads. `-Compact` saves a unique full JSONL report and prints one `REPORT` summary. The summary lists every inspected path, finding, decision, ACL action, verification, rollback state and `nextAction`. Decide from the summary; read specific records only when evidence is missing, not the implementation.
Substitute full, quoted paths. Keep `-Out` persistent and user-owned, preferably beside the failing workspace inside `-AllowRoot`; never use the skill resource directory, which is deleted when the skill unloads. Pass one path per invocation; do not repeat `-Path` or pass a comma-separated string to `pwsh -File`.
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-*` grants and an Everyone `DeleteSubdirectoriesAndFiles` deny; both are expected, not package conflicts. 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: a missing summary means unconfirmed completion, so inspect the report and recovery artifacts first.
## Decide whether diagnosis applies
| Verdict | Next step, only with complete observations |
Diagnose an unexpected denial of workspace writes, listing, or reads the signed-in user should plainly have. Root-only failure and uniform failure are both eligible.
Stop and explain expected denials instead: writes outside the workspace, any write in `read-only`, piped grandchild `spawn EPERM`, or ConstrainedLanguage errors for .NET/COM/reflection. Those need no ACL repair.
## Run it
1. Repeat the failing operation once in the current sandbox. If it succeeds, there is nothing to repair.
2. If it fails, run the command above confined once, if the sandbox starts at all. A writable `-Out` is enough; `read-only` refuses one.
3. When sandbox setup itself fails — the error names the workspace root or `SetNamedSecurityInfoW` — every confined call fails before your command runs, so do not retry confined. Request approval for that one command through the normal path and run it unconfined. Unconfined does not elevate the Windows token; it only lifts the file sandbox.
4. If approval is refused, unavailable, or forbidden by policy, report the path as undiagnosed and stop.
## Read the output
The script prints a `REPORT` JSON record for every observation, decision, action, verification and recovery, then `SUMMARY FIXED=<n> GRANTED=<n> REFUSED=<n> RESTORED=<n>`. Read all of it; nothing is summarized away.
Each record has `kind`, `operation`, `path`, `status`, `reason`, `details`. `completed` actions only confirm API execution: trust the `verification` records, which report `verified` or `failed` for each `grant` and each package removal. Decide from `details.nextAction`:
| `nextAction` | Meaning |
|---|---|
| `CULPRIT` | Explain the affected paths and individual package allow SIDs; select `-Fix`. |
| `PRECONDITION` | Explain missing effective `WRITE_DAC`/`WRITE_OWNER`; select `-GrantFullControl` if the caller has `WRITE_DAC`. |
| `BOTH` | Grant on the affected object first. Only after verified success may a separate invocation use `-Fix`. Failure ends this sequence. |
| `UNREADABLE`, `INCOMPLETE`, `NOT_THIS_CLASS` | Report observations and stop; do not infer a safe repair. |
| `verify_original_confined_operation` | Repairs verified; repeat the original operation confined. |
| `stop` | Nothing was repaired, or a refusal ended the run. Report and stop. |
| `restore_pending_then_stop` | Rollback was not verified. Run the printed recovery commands in order, then stop. |
## Run the selected repair
A deny ACE's presence alone does not establish causation; the script never removes one. DSH provisions `S-1-4-*` grants and an Everyone `DeleteSubdirectoriesAndFiles` deny: both are expected, not conflicts. A deny that blocks the repair ends the run with `REPAIR_REFUSED` and no change.
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. Never grant or repair a purely inherited copy: repair the explicit source, then re-read the child. 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.
Each modified object leaves exactly two files in `-Out`: `acl-backup-<id>.json` (the DACL that object owned before the change) and `acl-backup-<id>.json.ps1` (the script that restores it). The run prints the matching `ROLLBACK pwsh -NoProfile -File ... -Restore ...` command.
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, except that `-GrantFullControl` accepts the affected object itself so a caller can repair its own workspace root; `-Fix` still needs a strictly containing 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 or initiate UAC/`runas`.
## After a repair
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.
Repeat the original failed operation in the original confined context. A repair that verified is not undone because the original operation fails for a further reason: continue from the new observations. If a deeper path is still denied, run the same one command on that path — again one call, one approval.
For `verify_original_confined_operation`, repeat the original failed operation confined. A new observation continues the diagnosis; only a repair that failed its own verification restores this workflow's successful repairs in reverse order and stops. Do not start another repair cycle.
**Stop after any failed or refused repair, or any failed verification.** The script already restored that invocation's attempted changes; do not repeat the repair, do not continue to another one, and never remove a deny ACE by hand. Restore earlier successful invocations in reverse order with their exact printed commands, then report.
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. 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. Request each approval in plain words: the folder, what you will do, why it needs wider access, what stays unchanged. Keep the tool's error text, API names, SIDs and repair switches in the report. State the full plan before the first request, and never repeat a denied call.
## Never
- edit ACLs by hand, change an owner, erase a deny, or replace child permissions recursively;
- write probe files or run the script elevated, through UAC or `runas`; the script and its recovery copies are user-writable;
- widen `-AllowRoot` to reach an ancestor, or repair a purely inherited copy instead of its explicit source (the single command handles both);
- repeat a denied or failed call.
Report concisely in the user's language: observations and the paths analyzed (including relevant ancestors), what was changed and why, verification results, the recovery command, and the next step. Label any authorized unconfined run as such, not as a successful sandbox repair.
@@ -1,76 +1,65 @@
<#
.SYNOPSIS
Read-only diagnosis, and whitelisted repair, of Windows ACL objects that the
DSH sandbox cannot open.
Diagnose the DSH sandbox ACL failures on Windows and repair them in one run.
.DESCRIPTION
A denial inside the DSH sandbox is worth diagnosing only when it contradicts
what the active mode promises: writes inside the workspace, or reads that the
signed-in user should plainly have. This script reports why such an object is
unreadable or unopenable, and repairs package-ACE conflicts or missing rights.
signed-in user should plainly have. One invocation reads each requested path
and every ancestor, then repairs what it can in the same run:
Diagnosis (the default) never changes ACLs: it reads each requested path
and every ancestor with .NET and native icacls, and prints machine-parseable
lines for the caller to interpret. REPORT JSON records distinguish observed
facts, decisions and their reasons, attempted operations, and verification.
A caught exception still emits the operation history and recovery commands.
Failed repairs restore attempted DACL changes in reverse order and stop.
* every directory on that chain inside -AllowRoot that lacks effective
WRITE_DAC or WRITE_OWNER gets a FullControl allow ACE for the current
user, so the sandbox can provision the grant it needs; and
* every explicit AppContainer package allow ACE (S-1-15-2-*, excluding the
well-known groups ending in 1 or 2) is removed at its source, ancestor
first, which also removes that package's access to the tree.
Repair (-Fix) removes ONLY allow ACEs whose SID is an AppContainer package SID
(S-1-15-2-*, excluding the well-known groups ending in 1 or 2). Removing them
also removes that package's access. Repair requires
-AllowRoot, refuses any target outside it or reached through a reparse point,
and records a DACL backup with an independent recovery script. The grant checks
effective rights; removal verifies that other icacls lines remain unchanged.
A directory the caller cannot provision is the state the sandbox reports on the
workspace root, while the conflicting entry often sits deeper. For that case the
same run also collects explicit package allow ACEs under the requested directory,
so they do not need a second call. The walk is bounded, enters no reparse point or
managed application tree, and reports truncation when it stops early.
Every observation, change, verification and recovery command is printed as it
happens, so one run shows the caller the complete set of actions.
-AllowRoot bounds every change: an object is modified only when it is that
directory or strictly inside it, so a caller can repair its own workspace
root. Reparse paths and managed application trees are refused. Each modified
object is backed up first, with an independent recovery script, and every DACL
write is verified by re-reading it. A failed repair restores all attempted
DACL changes from this invocation in reverse order and stops.
The script never creates, deletes, or writes the contents of any file. Its
only content writes are recovery artifacts and explicitly requested reports.
only writes are the recovery artifacts under -Out and the DACLs it repairs.
.PARAMETER Path
One or more failing paths. Each is diagnosed together with its ancestors.
One or more failing paths. Each is diagnosed and repaired together with its ancestors.
.PARAMETER AllowRoot
Required with any mutation. Every modified object must be strictly inside this directory,
except that -GrantFullControl accepts this directory itself, so a caller can repair its own root.
Required. Every modified object must be this directory or strictly inside it.
.PARAMETER Out
Required with -Fix, -GrantFullControl or -Compact. Receives recovery artifacts
and, with -Compact, a unique full JSONL report.
.PARAMETER Compact
Save every REPORT record under -Out and print a decision summary with the
inspected paths, findings, actions, verification and pending recovery commands.
.PARAMETER Fix
Remove individual package allow ACEs, preserving well-known package groups.
Without a mutation switch, only diagnose.
.PARAMETER GrantFullControl
Grant the current user full control on the requested path. Full control is what
the documented precondition needs: it carries WRITE_DAC for the DACL and
WRITE_OWNER for the mandatory label DSH writes in the same call, while taking
ownership supplies only WRITE_DAC. The owner is never changed. Mutually
exclusive with -Fix.
Required for a repair. Receives one recovery record and one recovery script
per modified object.
.PARAMETER Restore
Restore the DACL recovery record for exactly one -Path. Requires -AllowRoot
and WRITE_DAC, preserves owner and SACL, and is exclusive with either repair.
Restore the DACL recovery record for exactly one -Path instead of repairing.
Requires -AllowRoot and WRITE_DAC, preserves owner and SACL.
.EXAMPLE
pwsh -File diagnose-windows-sandbox-acl.ps1 -Path 'C:\Users\me\.ssh'
pwsh -File diagnose-windows-sandbox-acl.ps1 -Path '<failing path>' -AllowRoot '<authorized directory>' -Out '<recovery directory>'
.EXAMPLE
pwsh -File diagnose-windows-sandbox-acl.ps1 -Path '<workspace>\.dsh-acl-fixture-x\case1' -AllowRoot '<workspace>\.dsh-acl-fixture-x' -Out '<workspace>\.dsh-acl-fixture-x\report' -Fix
pwsh -File diagnose-windows-sandbox-acl.ps1 -Path '<failing path>' -AllowRoot '<authorized directory>' -Restore '<recovery record>'
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string[]]$Path,
[string]$AllowRoot,
[string]$Out,
[switch]$Fix,
[switch]$GrantFullControl,
[string]$Restore,
[switch]$Compact
[string]$Restore
)
Set-StrictMode -Version Latest
@@ -79,30 +68,21 @@ $ErrorActionPreference = 'Stop'
# These two well-known groups name all packages, not one conflicting package.
$PACKAGE_SID = '^S-1-15-2-(?![12]$)'
$LOW_LABEL_SID = 'S-1-16-4096'
# Upper bound on the directories one subtree scan may visit, so a huge workspace
# cannot turn one approved repair into an unbounded walk. Truncation is reported.
$SCAN_LIMIT = 2000
function Write-Line { param([string]$Text) if (-not $Compact) { Write-Output $Text } }
function Write-Line { param([string]$Text) Write-Output $Text }
# Reports bypass the success pipeline so diagnostics cannot become a function's
# return value. Each JSON record occupies one stdout line, including paths/errors.
function Write-Report {
param([string]$Kind, [string]$Operation, [string]$Target, [string]$Status, [string]$Reason, $Details = @{})
$record = [ordered]@{ kind = $Kind; operation = $Operation; path = $Target; status = $Status; reason = $Reason; details = $Details }
$script:reports.Add($record)
if ($Kind -eq 'observation' -and $Status -in @('unknown', 'unreadable', 'partial')) { $script:observationFailures++ }
$json = $record | ConvertTo-Json -Depth 12 -Compress
if ($script:reportWriter) {
try { $script:reportWriter.WriteLine($json); $script:reportWriter.Flush() }
catch {
$script:reportWriter.Dispose()
$script:reportWriter = $null
$script:reportFailed = $true
[Console]::Out.WriteLine('REPORT ' + $json)
throw
}
}
if (-not $Compact -or $script:reportFailed) { [Console]::Out.WriteLine('REPORT ' + $json) }
[Console]::Out.WriteLine('REPORT ' + $json)
}
function Invoke-ReportedOperation {
param([string]$Operation, [string]$Target, [string]$Reason, [string]$Effect, [scriptblock]$Action)
$entry = [ordered]@{ id = $script:operations.Count + 1; operation = $Operation; path = $Target; effect = $Effect; status = 'started' }
@@ -280,6 +260,42 @@ function Get-RepairRefusal {
return $null
}
# A caller that cannot provision a directory also cannot confine a child anywhere
# under it, so one approved run examines that subtree for explicit package allow
# ACEs instead of leaving them for a second call. Directories only: DSH grants and
# traverses directories, and no reparse point or managed application tree is entered.
function Get-SubtreePackageSources {
param([string]$Root, [int]$Limit)
$sources = @()
$visited = 0
$enqueued = 0
$truncated = $false
$queue = [System.Collections.Generic.Queue[string]]::new()
try { foreach ($child in [System.IO.Directory]::EnumerateDirectories($Root)) { $queue.Enqueue($child); $enqueued++ } } catch { }
while ($queue.Count -gt 0) {
if ($visited -ge $Limit) { $truncated = $true; break }
$current = $queue.Dequeue()
$visited++
if (Test-DangerousRoot -FullPath $current) { continue }
$item = Get-Item -LiteralPath $current -Force -ErrorAction SilentlyContinue
if ($null -eq $item) { continue }
if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { continue }
try {
$acl = Get-Acl -LiteralPath $current
foreach ($rule in $acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])) {
if ($rule.AccessControlType -eq 'Allow' -and $rule.IdentityReference.Value -match $PACKAGE_SID) { $sources += $current; break }
}
} catch { }
try {
foreach ($child in [System.IO.Directory]::EnumerateDirectories($current)) {
if ($enqueued -ge $Limit) { $truncated = $true; break }
$queue.Enqueue($child); $enqueued++
}
} catch { }
}
return [ordered]@{ sources = @($sources); visited = $visited; truncated = $truncated }
}
function Get-OwnDaclSddl {
param([string]$Sddl)
# Inherited ACEs follow their parent: repeating them in a saved DACL forces them
@@ -304,17 +320,14 @@ function Save-AclBackup {
New-Item -ItemType Directory -Path $fullDirectory | Out-Null
}
}
$backup = Join-Path $fullDirectory ('acl-backup-{0}.txt' -f ([guid]::NewGuid().ToString('N')))
$record = $backup + '.json'
$record = Join-Path $fullDirectory ('acl-backup-{0}.json' -f ([guid]::NewGuid().ToString('N')))
# The skill's extracted directory expires with its registration. Recovery must
# remain runnable from the backup directory after that registration is gone.
$restoreScript = $backup + '.ps1'
# remain runnable from the recovery directory after that registration is gone.
$restoreScript = $record + '.ps1'
$command = "pwsh -NoProfile -File '{0}' -Path '{1}' -AllowRoot '{2}' -Restore '{3}'" -f
$restoreScript.Replace("'", "''"), $FullPath.Replace("'", "''"), ([System.IO.Path]::GetFullPath($Root)).Replace("'", "''"), $record.Replace("'", "''")
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')) }
Write-Report decision backup $FullPath selected 'The recovery record and an independent recovery script are written before the ACL change.' @{ files = @($record, $restoreScript) }
Invoke-ReportedOperation backup $FullPath 'Save the original DACL and an independent recovery command before changing permissions.' files {
$output = @(icacls $FullPath /save $backup 2>&1)
if ($LASTEXITCODE -ne 0) { throw "Could not back up the DACL (icacls exit $LASTEXITCODE): $($output -join "`n")" }
$backupAcl = Get-Acl -LiteralPath $FullPath
$observed = $backupAcl.GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access)
@{
@@ -324,17 +337,15 @@ function Save-AclBackup {
Observed = $observed
} | ConvertTo-Json | Set-Content -LiteralPath $record -Encoding utf8
Copy-Item -LiteralPath $PSCommandPath -Destination $restoreScript
$command | Set-Content -LiteralPath ($backup + '.rollback.txt')
}
$script:recoveries.Add(@{ path = $FullPath; backup = $backup; record = $record; script = $restoreScript; command = $command; attempted = $false; restored = $false })
$script:recoveries.Add(@{ path = $FullPath; record = $record; script = $restoreScript; command = $command; attempted = $false; restored = $false })
Write-Report observation recovery $FullPath available 'These files can restore the saved DACL; no rollback has been executed.' $script:recoveries[-1]
Write-Line ('BACKUP {0} -> {1}' -f $FullPath, $backup)
Write-Line ('BACKUP {0} -> {1}' -f $FullPath, $record)
Write-Line ('ROLLBACK {0}' -f $command)
}
function Restore-SavedDacl {
param([string]$FullPath, [string]$Record, [string]$Root)
$refusal = Get-RepairRefusal -FullPath $FullPath -Root $Root
$refusal = Get-RepairRefusal -FullPath $FullPath -Root $Root -AllowEqual
if ($refusal) { throw [System.ArgumentException]::new("RESTORE_REFUSED $refusal") }
$saved = Invoke-ReportedOperation read_recovery $Record 'Read the recovery record and verify it belongs to the requested path before restoring its DACL.' none {
Get-Content -LiteralPath $Record -Raw | ConvertFrom-Json
@@ -363,35 +374,6 @@ function Restore-SavedDacl {
Write-Line ('RESTORED {0}' -f $FullPath)
}
function Write-CompactSummary {
param([string]$Status, [string]$NextAction, [array]$RollbackCommands)
$byPath = [ordered]@{}
foreach ($record in $script:reports) { if ($record.operation -eq 'inspect_acl') { $byPath[$record.path] = $record } }
$observations = @($byPath.Values)
$classifications = @($script:reports | Where-Object { $_.operation -eq 'classify' })
$findings = @($observations | Where-Object {
$_.path -in $script:requestedPaths -or $_.status -ne 'read' -or
@($_.details['aces'] | Where-Object { $_ -and $_.sid -match $PACKAGE_SID -and $_.type -eq 'Allow' }).Count -gt 0
} | ForEach-Object {
@{ path = $_.path; status = $_.status; writeDac = $_.details['writeDac']; writeOwner = $_.details['writeOwner']
packageAllowSids = @($_.details['aces'] | Where-Object { $_ -and $_.sid -match $PACKAGE_SID -and $_.type -eq 'Allow' } | ForEach-Object { $_.sid })
denies = @($_.details['aces'] | Where-Object { $_ -and $_.type -eq 'Deny' }); errors = @($_.details['errors']; $_.details['error']) }
})
$details = [ordered]@{
report = $script:reportPath; exitCode = $script:exitCode; nextAction = $NextAction
inspectedPaths = @($observations | ForEach-Object { $_.path } | Select-Object -Unique)
findings = $findings
decisions = @($classifications | ForEach-Object { @{ path = $_.path; verdict = $_.status; reason = $_.reason; packageObjects = $_.details.packageObjects } })
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 } })
verification = @($script:reports | Where-Object { $_.kind -eq 'verification' } | ForEach-Object { @{ operation = $_.operation; path = $_.path; status = $_.status } })
rollback = $script:rollbackStatus; rollbackCommands = $RollbackCommands
errors = @($script:reports | Where-Object { $_.kind -eq 'error' -or ($_.kind -eq 'decision' -and $_.status -eq 'refused') } | ForEach-Object { @{ path = $_.path; reason = $_.reason; details = $_.details } })
}
$record = @{ kind = 'summary'; operation = $script:mode; path = $script:currentPath; status = $Status
reason = 'Full observations, actions and reasons are in the report. Follow nextAction; a failed repair must not be followed by another repair.'; details = $details }
[Console]::Out.WriteLine('REPORT ' + ($record | ConvertTo-Json -Depth 12 -Compress))
}
function Get-ObjectFacts {
param([string]$FullPath, [string]$MeSid)
$facts = [ordered]@{
@@ -483,10 +465,6 @@ function Get-Ancestors {
$operations = [System.Collections.Generic.List[object]]::new()
$recoveries = [System.Collections.Generic.List[object]]::new()
$reports = [System.Collections.Generic.List[object]]::new()
$reportWriter = $null
$reportPath = $null
$reportFailed = $false
$requestedPaths = @()
$rollbackStatus = 'not-needed'
$fixed = 0
@@ -495,45 +473,24 @@ $refused = 0
$restored = 0
$exitCode = 0
$observationFailures = 0
$scanTruncated = $false
$currentPath = ''
$mode = if ($Restore) { 'restore' } elseif ($GrantFullControl) { 'grant' } elseif ($Fix) { 'fix' } else { 'diagnose' }
$mode = if ($Restore) { 'restore' } else { 'repair' }
try {
$requestedPaths = @($Path | ForEach-Object { [System.IO.Path]::GetFullPath($_) })
Write-Report invocation $mode '' started 'Inspect the requested paths before deciding whether the requested operation can run.' @{
paths = $Path; allowRoot = $AllowRoot; outputDirectory = $Out; recoveryRecord = $Restore; fix = $Fix.IsPresent; grantFullControl = $GrantFullControl.IsPresent
}
if ($Compact) {
if (-not $Out) { throw [System.ArgumentException]::new('-Compact requires -Out for the complete report') }
Invoke-ReportedOperation prepare_report $Out 'Create the requested report directory and a new JSONL report; existing reports are never overwritten.' files {
$directory = [System.IO.Path]::GetFullPath($Out)
[System.IO.Directory]::CreateDirectory($directory) | Out-Null
$script:reportPath = Join-Path $directory ('acl-report-{0}.jsonl' -f [guid]::NewGuid().ToString('N'))
$stream = [System.IO.File]::Open($script:reportPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write)
$script:reportWriter = [System.IO.StreamWriter]::new($stream, [System.Text.UTF8Encoding]::new($false))
foreach ($record in $script:reports) { $script:reportWriter.WriteLine(($record | ConvertTo-Json -Depth 12 -Compress)) }
}
Write-Report invocation $mode '' started 'Inspect the requested paths, then repair the ACL problems this run can prove, before anything else is attempted.' @{
paths = $Path; allowRoot = $AllowRoot; outputDirectory = $Out; recoveryRecord = $Restore
}
if (-not $AllowRoot) { throw [System.ArgumentException]::new('Every modification requires -AllowRoot') }
if (-not $Restore -and -not $Out) { throw [System.ArgumentException]::new('A repair requires -Out for its recovery artifacts') }
if ($Restore -and $Path.Count -ne 1) { throw [System.ArgumentException]::new('-Restore requires exactly one -Path') }
$identity = Get-CurrentIdentity
$meSid = $identity.User.Value
Invoke-ReportedOperation initialize '' 'Load read-only access checks and DACL-only writes before inspecting permissions.' none { Initialize-NativeApi }
$integrity = Get-IntegritySid
Write-Line ('CALLER SID={0} INTEGRITY={1}' -f $meSid, $integrity)
Write-Report observation caller '' read 'The caller token determines effective access; unconfined execution does not imply elevation.' @{ sid = $meSid; integrity = $integrity }
if (@(@($Fix.IsPresent, $GrantFullControl.IsPresent, [bool]$Restore) | Where-Object { $_ }).Count -gt 1) {
throw [System.ArgumentException]::new('-Fix, -GrantFullControl and -Restore are separate operations; run one at a time')
}
if ($Fix -or $GrantFullControl -or $Restore) {
$requiredArguments = if ($Restore) { @('AllowRoot') } else { @('AllowRoot', 'Out') }
foreach ($required in $requiredArguments) {
if (-not (Get-Variable -Name $required -ValueOnly)) {
throw [System.ArgumentException]::new("The requested repair requires -$required")
}
}
if ($Restore -and $Path.Count -ne 1) { throw [System.ArgumentException]::new('-Restore requires exactly one -Path') }
}
:paths foreach ($requested in $Path) {
$currentPath = $requested
$full = Get-NormalizedPath $requested
@@ -543,23 +500,27 @@ try {
Write-Line ' MISSING'
Write-Line 'VERDICT=NOT_THIS_CLASS'
Write-Decision $mode $full skipped 'The requested path does not exist; no ACL was read or changed for it.'
if ($mode -ne 'diagnose') { $refused++; break paths }
continue
$refused++
break paths
}
$factsByPath = @{}
$inspectOrder = @()
$packageTargets = @()
$targetFacts = $null
foreach ($ancestor in (Get-Ancestors -FullPath $full)) {
if (-not (Test-Path -LiteralPath $ancestor)) { continue }
$f = Get-ObjectFacts -FullPath $ancestor -MeSid $meSid
if ($ancestor -eq $full) { $targetFacts = $f }
Write-Line (' OBJECT={0} READABLE={1}{2}' -f $f.Object, $f.Readable, $(if ($f.Readable) { '' } else { ' ERROR=' + $f.Error }))
if ($f.Readable) {
Write-Line (' OWNER={0} IS_CURRENT_USER={1} MY_RIGHTS=[{2}] WRITE_DAC={3} WRITE_OWNER={4} LOW_LABEL={5}' -f $f.Owner, $f.OwnerIsMe, $f.MyRights, $f.HasWriteDac, $f.HasWriteOwner, $f.LowLabel)
foreach ($ace in $f.PackageAces) { Write-Line (' PACKAGE_ACE {0}' -f $ace) }
foreach ($sid in ($f.OtherAppContainerSids | Sort-Object -Unique)) { Write-Line (' OTHER_S1_15 SID={0} (reported only)' -f $sid) }
$facts = Get-ObjectFacts -FullPath $ancestor -MeSid $meSid
$factsByPath[$facts.Object] = $facts
$inspectOrder += $facts.Object
if ($ancestor -eq $full) { $targetFacts = $facts }
Write-Line (' OBJECT={0} READABLE={1}{2}' -f $facts.Object, $facts.Readable, $(if ($facts.Readable) { '' } else { ' ERROR=' + $facts.Error }))
if ($facts.Readable) {
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)
foreach ($ace in $facts.PackageAces) { Write-Line (' PACKAGE_ACE {0}' -f $ace) }
foreach ($sid in ($facts.OtherAppContainerSids | Sort-Object -Unique)) { Write-Line (' OTHER_S1_15 SID={0} (reported only)' -f $sid) }
}
if ($f.PackageAces.Count -gt 0) { $packageTargets += $f }
if ($facts.PackageAces.Count -gt 0) { $packageTargets += $facts.Object }
}
# The precondition is judged on the requested path itself. Ancestors above the
@@ -574,7 +535,7 @@ try {
$verdict = 'INCOMPLETE'
$reason = 'Some observations failed; missing evidence is not evidence that a right is absent or a repair is safe.'
} elseif ($packageTargets.Count -gt 0) {
$blocked = @($packageTargets | Where-Object { -not $_.HasWriteDac })
$blocked = @($packageTargets | Where-Object { -not $factsByPath[$_].HasWriteDac })
$verdict = if ($needsPrecondition -or $blocked.Count -gt 0) { 'BOTH' } else { 'CULPRIT' }
$reason = 'Package allow ACEs were observed. Required access checks determine whether the caller can perform the requested repair; other causes remain possible.'
} elseif ($unreadable) {
@@ -588,126 +549,146 @@ try {
$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.'
}
Write-Line ('VERDICT={0}' -f $verdict)
Write-Report decision classify $full $verdict $reason @{ writeDac = $targetFacts.HasWriteDac; writeOwner = $targetFacts.HasWriteOwner; packageObjects = @($packageTargets | ForEach-Object { $_.Object }) }
if ($mode -eq 'diagnose') {
Write-Decision diagnose $full skipped 'No mutation mode was requested; all reported ACLs were left unchanged.'
continue
}
if ($targetFacts.Errors.Count -gt 0) {
Write-Decision $mode $full refused 'Required observations are incomplete; no repair was attempted for this path.'
$refused++; break paths
}
Write-Report decision classify $full $verdict $reason @{ writeDac = $targetFacts.HasWriteDac; writeOwner = $targetFacts.HasWriteOwner; packageObjects = @($packageTargets) }
if ($Restore) {
Restore-SavedDacl -FullPath $full -Record $Restore -Root $AllowRoot
continue
}
if ($targetFacts.Errors.Count -gt 0) {
Write-Decision $mode $full refused 'Required observations are incomplete; no repair was attempted for this path.'
$refused++
break paths
}
if ($GrantFullControl) {
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 }
# Granting the caller's own root to itself adds one ACE to that object and never walks to an ancestor.
$refusal = Get-RepairRefusal -FullPath $full -Root $AllowRoot -AllowEqual
if ($refusal) { Write-Line ('GRANT_REFUSED {0}' -f $refusal); Write-Decision grant $full refused $refusal; $refused++; break paths }
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 }
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 }
Save-AclBackup -FullPath $full -Directory $Out -Root $AllowRoot
# AddAccessRule preserves explicit denies; icacls /grant can remove a deny
# for the same principal. Write only the DACL, leaving owner and SACL intact.
$grantAcl = Get-Acl -LiteralPath $full
# A directory the caller cannot provision is the workspace root half the time: the
# sandbox reports its own failure there while the conflicting entry sits deeper.
# Examine that subtree in the same run instead of spending a second approval.
$ownsRoot = $full -ieq (Get-NormalizedPath $AllowRoot)
if ([System.IO.Directory]::Exists($full) -and ($needsPrecondition -or $ownsRoot)) {
$scan = Get-SubtreePackageSources -Root $full -Limit $SCAN_LIMIT
foreach ($object in $scan.sources) {
if ($packageTargets -notcontains $object) { $packageTargets += $object }
if (-not $factsByPath.ContainsKey($object)) {
$scanned = Get-ObjectFacts -FullPath $object -MeSid $meSid
$factsByPath[$scanned.Object] = $scanned
}
}
Write-Report observation subtree_scan $full read 'The caller cannot provision this directory, so explicit package allow ACEs under it were collected in the same run; no reparse point or managed application tree is entered.' @{
visited = $scan.visited; packageSources = @($scan.sources); truncated = [bool]$scan.truncated
}
Write-Line ('SCAN {0} VISITED={1} PACKAGE_SOURCES={2}{3}' -f $full, $scan.visited, @($scan.sources).Count, $(if ($scan.truncated) { ' TRUNCATED' } else { '' }))
if ($scan.truncated) { $script:scanTruncated = $true }
}
# Nothing is changed while a package source is out of reach: a repair that cannot
# finish would leave the tree half repaired and only then report the refusal.
foreach ($packagePath in $packageTargets) {
$refusal = Get-RepairRefusal -FullPath $packagePath -Root $AllowRoot -AllowEqual
if ($refusal) { Write-Line ('REPAIR_REFUSED {0}' -f $refusal); Write-Decision $mode $packagePath refused $refusal; $refused++; break paths }
if ($factsByPath[$packagePath].Errors.Count -gt 0) {
Write-Decision $mode $packagePath refused 'The ACL observations are incomplete; collateral changes could not be verified.'
$refused++
break paths
}
}
# Missing WRITE_DAC or WRITE_OWNER on a directory in the chain is what stops DSH
# from provisioning the workspace grant. One approved run repairs every such
# directory, including the authorized root itself.
$grantTargets = @($inspectOrder | Where-Object {
[System.IO.Directory]::Exists($_) -and $factsByPath[$_].Readable -and
((-not $factsByPath[$_].HasWriteDac) -or (-not $factsByPath[$_].HasWriteOwner)) -and
-not (Get-RepairRefusal -FullPath $_ -Root $AllowRoot -AllowEqual)
})
if ($grantTargets.Count -gt 0) {
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) }
}
foreach ($object in $grantTargets) {
$grantBefore = $factsByPath[$object]
Save-AclBackup -FullPath $object -Directory $Out -Root $AllowRoot
$grantAcl = Get-Acl -LiteralPath $object
$grantAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
[System.Security.Principal.SecurityIdentifier]::new($meSid),
[System.Security.AccessControl.FileSystemRights]::FullControl,
[System.Security.AccessControl.AccessControlType]::Allow))
# Write own ACEs only: an inherited ACE written back becomes explicit on this object.
$grantAcl.SetAccessRuleProtection($grantAcl.AreAccessRulesProtected, $false)
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 {
[Dsh.TokenInfo]::SetDacl($full, $grantAcl.GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access))
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 {
[Dsh.TokenInfo]::SetDacl($object, $grantAcl.GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access))
}
$after = Get-ObjectFacts -FullPath $full -MeSid $meSid
$verified = $after.Readable -and $after.Errors.Count -eq 0 -and $after.HasWriteDac -and $after.HasWriteOwner
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.' @{
before = @{ writeDac = $targetFacts.HasWriteDac; writeOwner = $targetFacts.HasWriteOwner }
after = @{ writeDac = $after.HasWriteDac; writeOwner = $after.HasWriteOwner }
$grantAfter = Get-ObjectFacts -FullPath $object -MeSid $meSid
$factsByPath[$object] = $grantAfter
$verified = $grantAfter.Readable -and $grantAfter.Errors.Count -eq 0 -and $grantAfter.HasWriteDac -and $grantAfter.HasWriteOwner
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.' @{
before = @{ writeDac = $grantBefore.HasWriteDac; writeOwner = $grantBefore.HasWriteOwner }
after = @{ writeDac = $grantAfter.HasWriteDac; writeOwner = $grantAfter.HasWriteOwner }
recovery = $recoveries[-1].command
}
if ($verified) {
Write-Line ('GRANTED {0} SID={1}' -f $full, $meSid); $granted++
Write-Line ('GRANTED {0} SID={1}' -f $object, $meSid); $granted++
} else {
Write-Line ('GRANT_FAILED {0}; effective WRITE_DAC and WRITE_OWNER were not both confirmed; restore this invocation and stop' -f $full)
Write-Line ('GRANT_FAILED {0}; effective WRITE_DAC and WRITE_OWNER were not both confirmed; restore this invocation and stop' -f $object)
$refused++
break paths
}
continue
}
if (-not $Fix) { continue }
if ($unreadable) {
Write-Line 'FIX_REFUSED the object could not be read; repair needs an unconfined caller'
Write-Decision fix $full refused 'The requested ACL could not be read; no package removal was attempted.'
$refused++
break paths
}
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.' }
$sources = @($packageTargets | Where-Object {
@($_.Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited }).Count -gt 0
@($factsByPath[$_].Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited }).Count -gt 0
})
[array]::Reverse($sources)
foreach ($target in (@($sources) + @($packageTargets))) {
$refusal = Get-RepairRefusal -FullPath $target.Object -Root $AllowRoot
if ($refusal) { Write-Line ('FIX_REFUSED {0}' -f $refusal); Write-Decision fix $target.Object refused $refusal; $refused++; break paths }
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 }
}
if ($packageTargets.Count -gt 0 -and $needsPrecondition) {
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.'
$refused++; break paths
}
foreach ($target in $sources) {
if (-not $target.HasWriteDac) {
Write-Line ('FIX_REFUSED {0} needs WRITE_DAC; stop for permission-policy review' -f $target.Object)
Write-Decision fix $target.Object refused 'Effective WRITE_DAC is absent; removing a package allow ACE requires that right.'
foreach ($source in $sources) {
if (-not $factsByPath[$source].HasWriteDac) {
Write-Line ('REPAIR_REFUSED {0} needs WRITE_DAC; stop for permission-policy review' -f $source)
Write-Decision $mode $source refused 'Effective WRITE_DAC is absent; removing a package allow ACE requires that right.'
$refused++
break paths
}
}
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.' @{
sources = @($sources | ForEach-Object { $_.Object }); affectedPaths = @($packageTargets | ForEach-Object { $_.Object })
if ($sources.Count -gt 0) {
Write-Report decision remove_package_sources $full selected 'Remove explicit package allow ACEs from their sources, ancestor first. Inherited entries are verified after the source changes; inheritance stays enabled.' @{
sources = @($sources); affectedPaths = @($packageTargets)
}
}
foreach ($target in $sources) {
Save-AclBackup -FullPath $target.Object -Directory $Out -Root $AllowRoot
$sids = @($target.Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited } | ForEach-Object { $_.sid } | Sort-Object -Unique)
foreach ($source in $sources) {
Save-AclBackup -FullPath $source -Directory $Out -Root $AllowRoot
$sids = @($factsByPath[$source].Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited } | ForEach-Object { $_.sid } | Sort-Object -Unique)
foreach ($sid in $sids) {
$repairPath = $target.Object
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 {
$output = @(icacls $repairPath /remove:g "*$sid" 2>&1)
if ($LASTEXITCODE -ne 0) { throw "icacls removal exit ${LASTEXITCODE}: $($output -join "`n")" }
Invoke-ReportedOperation remove_package_allow $source "Remove the observed package allow ACE for $sid; preserve deny ACEs and all other principals." acl {
$output = @(icacls $source /remove:g "*$sid" 2>&1)
if ($LASTEXITCODE -ne 0) { throw "icacls removal exit $($LASTEXITCODE): $($output -join [char]10)" }
}
}
}
foreach ($target in $packageTargets) {
$after = Get-ObjectFacts -FullPath $target.Object -MeSid $meSid
# A package deny can share the removed allow's SID; only allow ACEs were removed.
foreach ($packagePath in $packageTargets) {
$fixBefore = $factsByPath[$packagePath]
$fixAfter = Get-ObjectFacts -FullPath $packagePath -MeSid $meSid
# Compare the object's own ACEs as sorted sets: listing order and inherited
# entries follow the parent, so neither proves a collateral change.
$rowOf = { param($ace) '{0}|{1}|{2}|{3}|{4}' -f $ace.type, $ace.sid, $ace.rights, $ace.inheritance, $ace.propagation }
$ownBefore = @($target.Aces | Where-Object { -not $_.inherited } | ForEach-Object { & $rowOf $_ } | Sort-Object)
$ownAfter = @($after.Aces | Where-Object { -not $_.inherited } | ForEach-Object { & $rowOf $_ } | Sort-Object)
# A package deny can share the removed allow's SID; only allow ACEs were removed.
$expectedOwn = @($target.Aces | Where-Object { -not $_.inherited -and -not ($_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID) } | ForEach-Object { & $rowOf $_ } | Sort-Object)
$collateral = (@($expectedOwn) -join "`n") -eq (@($ownAfter) -join "`n")
$verified = $after.Readable -and $after.Errors.Count -eq 0 -and $after.PackageAces.Count -eq 0 -and $collateral
Write-Report verification fix $target.Object $(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.' @{
remainingPackageAces = $after.PackageAces; otherEntriesUnchanged = $collateral
$ownAfter = @($fixAfter.Aces | Where-Object { -not $_.inherited } | ForEach-Object { & $rowOf $_ } | Sort-Object)
$expectedOwn = @($fixBefore.Aces | Where-Object { -not $_.inherited -and -not ($_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID) } | ForEach-Object { & $rowOf $_ } | Sort-Object)
$collateral = (@($expectedOwn) -join [char]10) -eq (@($ownAfter) -join [char]10)
$verified = $fixAfter.Readable -and $fixAfter.Errors.Count -eq 0 -and $fixAfter.PackageAces.Count -eq 0 -and $collateral
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.' @{
remainingPackageAces = $fixAfter.PackageAces; otherEntriesUnchanged = $collateral
}
if ($verified) {
$explicitSids = @($target.Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited } | ForEach-Object { $_.sid } | Sort-Object -Unique)
foreach ($sid in $explicitSids) { Write-Line ('FIXED {0} SID={1}' -f $target.Object, $sid); $fixed++ }
$removedSids = @($fixBefore.Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited } | ForEach-Object { $_.sid } | Sort-Object -Unique)
foreach ($sid in $removedSids) {
Write-Line ('FIXED {0} SID={1}' -f $packagePath, $sid); $fixed++
}
} else {
Write-Line ('FIX_FAILED {0} collateral_change={1}' -f $target.Object, (-not $collateral))
Write-Line ('FIX_FAILED {0} collateral_change={1}' -f $packagePath, (-not $collateral))
$refused++
break paths
}
}
if ($packageTargets.Count -eq 0 -and $grantTargets.Count -eq 0) {
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.'
}
}
} catch {
$exitCode = if ($_.Exception -is [System.ArgumentException]) { 2 } else { 1 }
@@ -732,15 +713,14 @@ try {
}
$pending = @($recoveries | Where-Object { $_.attempted -and -not $_.restored } | ForEach-Object { $_.command })
[array]::Reverse($pending)
$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' }
$repaired = ($fixed + $granted) -gt 0
$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' }
Write-Line ('SUMMARY FIXED={0} GRANTED={1} REFUSED={2} RESTORED={3}' -f $fixed, $granted, $refused, $restored)
$status = if ($exitCode -ne 0) { 'failed' } elseif ($observationFailures -gt 0) { 'partial' } else { 'completed' }
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.' @{
exitCode = $exitCode; fixed = $fixed; granted = $granted; refused = $refused; restored = $restored
operations = @($operations.ToArray()); recoveries = @($recoveries.ToArray()); automaticRollback = $true; observationFailures = $observationFailures
rollback = $rollbackStatus; rollbackCommands = $pending; nextAction = $nextAction; report = $reportPath
rollback = $rollbackStatus; rollbackCommands = $pending; nextAction = $nextAction; scanTruncated = $scanTruncated
}
if ($reportWriter) { $reportWriter.Dispose() }
if ($Compact) { Write-CompactSummary -Status $status -NextAction $nextAction -RollbackCommands $pending }
}
exit $exitCode
File diff suppressed because it is too large. Load diff
@@ -1,14 +0,0 @@
decision classify PRECONDITION path={{target}}: 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.
decision backup selected path={{target}}: All recovery files must be written successfully before attempting the ACL change.
action backup started path={{target}}: Save the original DACL and an independent recovery command before changing permissions.
action backup completed path={{target}}: Save the original DACL and an independent recovery command before changing permissions.
action grant_dacl started path={{target}}: WRITE_OWNER is missing and WRITE_DAC is available; add a FullControl allow ACE for {{caller_sid}} while preserving deny ACEs, owner and SACL.
action grant_dacl completed path={{target}}: WRITE_OWNER is missing and WRITE_DAC is available; add a FullControl allow ACE for {{caller_sid}} while preserving deny ACEs, owner and SACL.
verification grant failed path={{target}}: The DACL write completed; recheck effective access before claiming that provisioning can succeed. On failure, restore this invocation and stop.
decision rollback selected path={{target}}: The repair failed; restore every attempted DACL change from this invocation in reverse order before stopping.
action restore_dacl started path={{target}}: Restore the requested backup DACL and inheritance protection, preserving owner and SACL.
action restore_dacl completed path={{target}}: Restore the requested backup DACL and inheritance protection, preserving owner and SACL.
action read_restored_dacl started path={{target}}: Read the restored DACL to compare it with the saved record.
action read_restored_dacl completed path={{target}}: Read the restored DACL to compare it with the saved record.
verification restore verified path={{target}}: Compare the restored own ACEs and protection state with the recovery record.
summary grant failed path={{target}}: Operation completion records API execution; verification records the observed result. Only rerunning the original confined operation can confirm its failure is resolved.
@@ -1,5 +0,0 @@
{"kind":"observation","operation":"inspect_acl","path":"{{parent}}\\child","status":"read","reason":"Read the ACL and check effective WRITE_DAC and WRITE_OWNER; observed ACEs alone do not identify which rule caused a denial.","details":{"fixturePackageAces":[]}}
{"kind":"observation","operation":"inspect_acl","path":"{{parent}}","status":"read","reason":"Read the ACL and check effective WRITE_DAC and WRITE_OWNER; observed ACEs alone do not identify which rule caused a denial.","details":{"fixturePackageAces":[{"sid":"S-1-15-2-1-2-3-4","type":"Allow","rights":"ReadAndExecute, Synchronize","inherited":false,"inheritance":"None","propagation":"None"}]}}
{"kind":"decision","operation":"classify","path":"{{parent}}\\child","status":"CULPRIT","reason":"Package allow ACEs were observed. Required access checks determine whether the caller can perform the requested repair; other causes remain possible.","details":{"packageObjects":["{{parent}}"]}}
{"kind":"decision","operation":"diagnose","path":"{{parent}}\\child","status":"skipped","reason":"No mutation mode was requested; all reported ACLs were left unchanged.","details":{}}
{"kind":"summary","operation":"diagnose","path":"{{parent}}\\child","status":"completed","reason":"Operation completion records API execution; verification records the observed result. Only rerunning the original confined operation can confirm its failure is resolved.","details":{"exitCode":0,"operations":[{"id":1,"operation":"initialize","path":"","effect":"none","status":"completed"}],"automaticRollback":true}}
@@ -41,7 +41,7 @@ it.skipIf(process.platform !== 'win32')('loads and unloads the ACL skill through
expect((await ctx.skills.list()).map(entry => entry.name)).toEqual([ACL_DIAGNOSIS_SKILL])
const loaded = await ctx.skills.get(ACL_DIAGNOSIS_SKILL)
expect(loaded).toMatchObject({ source: 'bundled', provider: 'dsh-windows-acl' })
expect(loaded?.content).toContain('Stop after any failed/refused repair')
expect(loaded?.content).toContain('Stop after any failed or refused repair')
if (loaded?.resourceBase?.kind !== 'directory') throw new Error('Missing extracted skill resources')
const resources = loaded.resourceBase.path
expect(readFileSync(join(resources, 'scripts/diagnose-windows-sandbox-acl.ps1'), 'utf8'))