From 4f96bc18dff6972b4449b3f91fa0d93e42f2dd66 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 28 Sep 2026 19:05:48 +0800 Subject: [PATCH] fix(windows): verify ACL repairs against the object's own entries Comparing whole DACL strings and whole icacls listings reported a successful package-ACE removal as a collateral change: listing order, inherited entries and the object's own entries were treated as one value. The package allow filter also matched the rendered ACE row instead of the ACE's SID, so -Fix could never verify a removal. Compare the object's own ACEs and its inheritance protection instead, save and restore own ACEs only so a parent that changes during the repair cannot make a correct restore fail, and let -GrantFullControl accept the authorized root itself so a caller can repair its own workspace root. --- .../diagnose-windows-sandbox-acl/SKILL.md | 18 ++-- .../scripts/diagnose-windows-sandbox-acl.ps1 | 85 ++++++++++++------- .../tests/diagnose-script.spec.ts | 25 +++++- .../tests/expected/denied-grant-report.txt | 2 +- 4 files changed, 89 insertions(+), 41 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md index 68f3a1f8df..de1f997181 100644 --- a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md +++ b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md @@ -5,26 +5,26 @@ description: 'Use on Windows for unexpected DSH sandbox access denials: workspac # 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 later backup alone cannot undo earlier repairs. +**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. ## Decide whether diagnosis applies -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. +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. 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. ## 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` 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`. +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`. ```powershell & '\scripts\diagnose-windows-sandbox-acl.ps1' -Path '' -Out '' -Compact exit $LASTEXITCODE ``` -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. +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. -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. +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. | Verdict | Next step, only with complete observations | |---|---| @@ -35,12 +35,12 @@ Each full record has `kind`, `operation`, `path`, `status`, `reason`, `details`. ## Run the selected repair -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. +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. -Append `-AllowRoot ''` 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. +Append `-AllowRoot ''` 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`. 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. -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. +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. -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. +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. diff --git a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 index 3106e444fa..a7713aa802 100644 --- a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 +++ b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 @@ -30,7 +30,8 @@ One or more failing paths. Each is diagnosed together with its ancestors. .PARAMETER AllowRoot - Required with any mutation. Every modified object must be strictly inside this directory. + 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. .PARAMETER Out Required with -Fix, -GrantFullControl or -Compact. Receives recovery artifacts @@ -257,15 +258,16 @@ function Test-DangerousRoot { } function Test-UnderRoot { - param([string]$FullPath, [string]$Root) + param([string]$FullPath, [string]$Root, [switch]$AllowEqual) $r = Get-NormalizedPath $Root $c = Get-NormalizedPath $FullPath + if ($AllowEqual -and $c -ieq $r) { return $true } return $c -ine $r -and $c.StartsWith($r.TrimEnd('\') + '\', [System.StringComparison]::OrdinalIgnoreCase) } function Get-RepairRefusal { - param([string]$FullPath, [string]$Root) - if (-not (Test-UnderRoot -FullPath $FullPath -Root $Root)) { return "$FullPath is outside -AllowRoot" } + param([string]$FullPath, [string]$Root, [switch]$AllowEqual) + if (-not (Test-UnderRoot -FullPath $FullPath -Root $Root -AllowEqual:$AllowEqual)) { return "$FullPath is outside -AllowRoot" } $danger = Test-DangerousRoot -FullPath $FullPath if ($danger) { return "$FullPath is a $danger" } # Checking every component also covers a junction used as AllowRoot itself. @@ -278,6 +280,22 @@ function Get-RepairRefusal { return $null } +function Get-OwnDaclSddl { + param([string]$Sddl) + # Inherited ACEs follow their parent: repeating them in a saved DACL forces them + # back onto the object on restore and stops matching once the parent re-derives + # them. Keep the observed control flags so the result still carries the + # object's inheritance protection, and keep every ACE the object owns. + $flags = [regex]::Match($Sddl, '^D:([A-Z]*)').Groups[1].Value + $aces = @([regex]::Matches($Sddl, '\([^()]*\)') | ForEach-Object { $_.Value } | Where-Object { ($_ -split ';')[1] -notmatch 'ID' }) + return 'D:{0}{1}' -f $flags, ($aces -join '') +} + +function Get-AceSet { + param([string]$Sddl) + return @([regex]::Matches($Sddl, '\([^()]*\)') | ForEach-Object { $_.Value } | Sort-Object) +} + function Save-AclBackup { param([string]$FullPath, [string]$Directory, [string]$Root) $fullDirectory = [System.IO.Path]::GetFullPath($Directory) @@ -297,8 +315,14 @@ function Save-AclBackup { 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")" } - @{ Path = $FullPath; Dacl = (Get-Acl -LiteralPath $FullPath).GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access) } | - ConvertTo-Json | Set-Content -LiteralPath $record -Encoding utf8 + $backupAcl = Get-Acl -LiteralPath $FullPath + $observed = $backupAcl.GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access) + @{ + Path = $FullPath + Dacl = Get-OwnDaclSddl -Sddl $observed + Protected = $backupAcl.AreAccessRulesProtected + Observed = $observed + } | ConvertTo-Json | Set-Content -LiteralPath $record -Encoding utf8 Copy-Item -LiteralPath $PSCommandPath -Destination $restoreScript $command | Set-Content -LiteralPath ($backup + '.rollback.txt') } @@ -322,11 +346,19 @@ function Restore-SavedDacl { Invoke-ReportedOperation restore_dacl $FullPath 'Restore the requested backup DACL and inheritance protection, preserving owner and SACL.' acl { [Dsh.TokenInfo]::SetDacl($FullPath, $saved.Dacl) } - $actual = Invoke-ReportedOperation read_restored_dacl $FullPath 'Read the restored DACL to compare it with the saved record.' none { - (Get-Acl -LiteralPath $FullPath).GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access) + $restoredAcl = Invoke-ReportedOperation read_restored_dacl $FullPath 'Read the restored DACL to compare it with the saved record.' none { + Get-Acl -LiteralPath $FullPath } - 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 } - if ($actual -ne $saved.Dacl) { throw 'RESTORE_FAILED the restored DACL differs from the backup' } + # Inherited entries follow the parent, so the same object can render a different + # full DACL before and after a correct restore. Compare the own ACEs and the + # protection state, which the restore owns. + $restoredProtected = $restoredAcl.AreAccessRulesProtected + $actual = Get-OwnDaclSddl -Sddl $restoredAcl.GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access) + $expectedAces = Get-AceSet -Sddl $saved.Dacl + $actualAces = Get-AceSet -Sddl $actual + $restored = (@($expectedAces) -join "`n") -eq (@($actualAces) -join "`n") -and $restoredProtected -eq [bool]$saved.Protected + 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 } + if (-not $restored) { throw 'RESTORE_FAILED the restored DACL differs from the backup' } $script:restored++ Write-Line ('RESTORED {0}' -f $FullPath) } @@ -574,7 +606,8 @@ try { 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 } - $refusal = Get-RepairRefusal -FullPath $full -Root $AllowRoot + # 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 } @@ -586,6 +619,8 @@ try { [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)) } @@ -651,27 +686,17 @@ try { } } foreach ($target in $packageTargets) { - $before = $target.AclLines $after = Get-ObjectFacts -FullPath $target.Object -MeSid $meSid - # icacls prefixes only the first ACE with the path, so compare entries - # without that prefix when checking for collateral changes. - $keepBefore = @($before | ForEach-Object { - $line = $_.Trim() - if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() } - $line - } | Where-Object { $_ -notmatch $PACKAGE_SID }) - $keepAfter = @($after.AclLines | ForEach-Object { - $line = $_.Trim() - if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() } - $line - } | Where-Object { $_ -notmatch $PACKAGE_SID }) - # A package deny can share the removed allow's SID. Preserve it explicitly - # even though native listing comparison omits lines naming package SIDs. - $expectedAces = @($target.Aces | Where-Object { -not ($_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID) }) - $collateral = (($keepBefore -join "`n") -eq ($keepAfter -join "`n")) -and - (($expectedAces | ConvertTo-Json -Compress) -eq ($after.Aces | ConvertTo-Json -Compress)) + # 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 ACL listing entries remained unchanged. On failure, restore this invocation and stop.' @{ + 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 } if ($verified) { diff --git a/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts index 7e526139f9..8de7cdf1cd 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts @@ -614,6 +614,28 @@ exit $LASTEXITCODE expect([parent, child].map(sddlOf)).toEqual(before) }) + it('grants when the authorized root is the object itself, so a caller can repair its own workspace root', () => { + const root = makeDir('root-is-allow-root') + const canonical = runScript(['-Path', root, '-AllowRoot', root, '-Out', outDir, '-GrantFullControl']) + expect(canonical.code, canonical.output).toBe(0) + expect(reports(canonical)).toContainEqual(containingObject({ kind: 'decision', operation: 'grant', path: root })) + for (const spelling of [`${root}\\`, `${root}/`, root.toUpperCase()]) { + const run = runScript(['-Path', spelling, '-AllowRoot', root, '-Out', outDir, '-GrantFullControl']) + expect(run.code, run.output).toBe(0) + expect(reports(run).some(entry => entry.reason.includes('outside -AllowRoot'))).toBe(false) + } + }) + + it('still refuses -Fix when the authorized root is the object itself', () => { + const root = makeDir('root-fix-refused') + icacls(root, '/grant', `*${PACKAGE_SID}:(OI)(CI)(RX)`) + const before = sddlOf(root) + const run = runScript(['-Path', root, '-AllowRoot', root, '-Out', outDir, '-Fix']) + expect(run.code).toBe(2) + expect(reports(run)).toContainEqual(containingObject({ kind: 'decision', status: 'refused', path: root, reason: containingString('outside -AllowRoot') })) + expect(sddlOf(root)).toBe(before) + }) + it('restores explicit sources and inherited entries when final verification fails', () => { const parent = makeDir('inherited-rollback') const child = join(parent, 'child') @@ -645,7 +667,8 @@ exit $LASTEXITCODE expect(paths.map(sddlOf)).toEqual(before) }) - it.each(['-Fix', '-GrantFullControl', '-Restore'])('rejects equivalent spellings of AllowRoot through %s', (mode) => { + // -GrantFullControl accepts the object itself, so only the removing modes still reject an equivalent root spelling. + it.each(['-Fix', '-Restore'])('rejects equivalent spellings of AllowRoot through %s', (mode) => { const target = makeDir(`root-spellings-${mode}`) const backups = makeDir(`root-backups-${mode}`) icacls(target, '/inheritance:r', '/grant:r', `*${meSid}:(M)`) diff --git a/packages/sandbox/sandbox-windows-acl/tests/expected/denied-grant-report.txt b/packages/sandbox/sandbox-windows-acl/tests/expected/denied-grant-report.txt index 0e1ad3333b..1684e3aeb6 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/expected/denied-grant-report.txt +++ b/packages/sandbox/sandbox-windows-acl/tests/expected/denied-grant-report.txt @@ -10,5 +10,5 @@ action restore_dacl started path={{target}}: Restore the requested backup DACL a 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 observed DACL with the recovery record after writing it. +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.