Merge pull request #5432 from deepseek-harness/fix/windows-acl-single-run-repair
fix(sandbox-windows-acl): diagnose and repair ACL denials in one run
This commit is contained in:
commit
7cecc72cae
14 files changed
+1064
-894
No files matched your search
+2
-2
@@ -12,8 +12,8 @@
|
||||
en: 125dea7427047966
|
||||
zh: 1e459b1106597695
|
||||
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery:
|
||||
en: 8ea2f91a914d540b
|
||||
zh: cafada631044e43a
|
||||
en: a5bcdf2a6133abfe
|
||||
zh: 477d4915d61945e3
|
||||
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/how-the-restriction-works-why-no-new-identity:
|
||||
en: ac9b3d800889ea39
|
||||
zh: 9db1065b6027f65f
|
||||
|
||||
+2
-2
@@ -16,9 +16,9 @@ Implement the rung directly on the raw ACL mechanism: duplicate the caller's tok
|
||||
|
||||
The bundled diagnosis skill runs its preserving ACL repairs outside the confined token because that token cannot inspect the conflicting objects. External PowerShell requires physical script files, so registration extracts resources from ASAR or SEA instead of exposing archive paths. The fiber owns that temporary copy. Recovery files live in the caller's persistent output directory so disposal of the skill does not remove the means to restore a DACL.
|
||||
|
||||
A repair backs up before mutation and verifies effective access or the resulting ACEs. Failed invocations restore attempted changes in reverse order and remain failures: continuing after a failed grant would remove access without establishing the prerequisite. Inherited package entries are repaired at their explicit sources, preserving inheritance; incomplete or out-of-scope sources prevent mutation. Labels and denies remain untouched because removing them could defeat the backend's confinement or an intentional user policy.
|
||||
A repair backs up before mutation and verifies effective access or the resulting ACEs. One invocation performs every repair it can prove, so a single sandbox failure needs one approval instead of a diagnosis followed by a repair: it adds the caller's full-control ACE to the chain directories that lack effective `WRITE_DAC` or `WRITE_OWNER`, and removes explicit package allow ACEs at their sources. The authorized root itself may be modified and restored, so a workspace root can repair and roll itself back, and recovery artifacts are one DACL record plus one copy of the restoring script per modified object. When the requested directory is one the caller cannot provision — the object a provisioning error names — that same invocation also examines its subtree for explicit package allow ACEs through a bounded walk that reports truncation, because the conflicting entry often sits below the reported root. Failed invocations restore attempted changes in reverse order and remain failures: continuing after a failed change would remove access without establishing the prerequisite. Inherited package entries are repaired at their explicit sources, preserving inheritance; incomplete or out-of-scope sources prevent mutation. Labels and denies remain untouched because removing them could defeat the backend's confinement or an intentional user policy.
|
||||
|
||||
Both extracted and recovery scripts are user-writable, so this workflow never recommends elevating them. Missing unconfined WRITE_DAC stops the workflow for permission-policy review. Automatic recovery can itself fail and cannot survive process termination; reports retain pending recovery commands rather than promising an atomic transaction. The operator keeps those artifacts until verification of the original confined operation succeeds.
|
||||
Both extracted and recovery scripts are user-writable, so this workflow never recommends elevating them. Missing unconfined WRITE_DAC stops the workflow for permission-policy review. Automatic recovery can itself fail and cannot survive process termination; reports retain pending recovery commands rather than promising an atomic transaction. Because tool output keeps only its tail, every record is also written to a report file under the output directory and the run ends with a recap line, so the decisions survive truncation. The operator keeps those artifacts until verification of the original confined operation succeeds.
|
||||
|
||||
## How the restriction works (why no new identity)
|
||||
|
||||
|
||||
+2
-2
@@ -17,9 +17,9 @@ Status: implemented
|
||||
|
||||
内置诊断技能在受限令牌之外执行保留原有权限的 ACL 修复,因为受限令牌无法检查发生冲突的对象。外部 PowerShell 需要实际脚本文件,因此注册时从 ASAR 或 SEA 提取资源,而不提供归档内路径。fiber 拥有该临时副本。恢复文件保存在调用者指定的持久输出目录中,确保技能释放后仍能恢复 DACL。
|
||||
|
||||
修复在修改前备份,并验证有效权限或生成的 ACE。失败调用按相反顺序恢复尝试修改的内容,且仍视为失败:授权失败后继续操作,会在前置条件尚未满足时移除访问权限。继承的包条目从显式来源处修复,保持继承不变;来源信息不完整或超出范围时不做修改。标签与拒绝条目保持不变,因为移除它们可能破坏后端隔离或用户有意设置的策略。
|
||||
修复在修改前备份,并验证有效权限或生成的 ACE。一次调用会完成它能证明的全部修复,因此单个沙箱失败只需一次批准,而不必先诊断再修复:为链路上缺少有效 `WRITE_DAC` 或 `WRITE_OWNER` 的目录添加调用者自身的完全控制 ACE,并在来源处移除显式的包允许 ACE。授权根目录本身也可以被修改和恢复,因此工作区根目录能自我修复并回滚;恢复产物为每个被修改对象一份 DACL 记录和一份恢复脚本副本。当请求的目录正是调用者无法授权的那一个——即授权失败错误所指向的对象——同一次调用还会通过受上限约束、并在截断时如实报告的遍历,检查其子树中的显式包允许 ACE,因为冲突条目常常位于被报告的根目录之下。失败调用按相反顺序恢复尝试修改的内容,且仍视为失败:某次修改失败后继续操作,会在前置条件尚未满足时移除访问权限。继承的包条目从显式来源处修复,保持继承不变;来源信息不完整或超出范围时不做修改。标签与拒绝条目保持不变,因为移除它们可能破坏后端隔离或用户有意设置的策略。
|
||||
|
||||
提取的脚本和恢复脚本均可由用户写入,因此此流程绝不建议提权运行它们。不受限调用者缺少 WRITE_DAC 时,流程停止并交由权限策略审查处理。自动恢复本身也可能失败,且无法跨进程终止继续执行;报告保留待执行的恢复命令,不承诺原子事务。操作者应保留这些文件,直到原始受限操作验证成功。
|
||||
提取的脚本和恢复脚本均可由用户写入,因此此流程绝不建议提权运行它们。不受限调用者缺少 WRITE_DAC 时,流程停止并交由权限策略审查处理。自动恢复本身也可能失败,且无法跨进程终止继续执行;报告保留待执行的恢复命令,不承诺原子事务。由于工具输出只保留末尾部分,全部记录还会写入输出目录下的报告文件,运行结束时给出一行摘要,使判定在截断后仍然可见。操作者应保留这些文件,直到原始受限操作验证成功。
|
||||
|
||||
## How the restriction works (why no new identity)
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ try {
|
||||
const directory = skill.resourceBase.path;
|
||||
assert.ok(!directory.includes('.asar'));
|
||||
const output = execFileSync('pwsh', ['-NoProfile', '-NonInteractive', '-File',
|
||||
join(directory, 'scripts/diagnose-windows-sandbox-acl.ps1'), '-Path', root],
|
||||
join(directory, 'scripts/diagnose-windows-sandbox-acl.ps1'), '-Path', root, '-AllowRoot', root, '-Out', join(root, 'out')],
|
||||
{ encoding: 'utf8', timeout: 30000, windowsHide: true });
|
||||
assert.ok(output.includes('READABLE=True'));
|
||||
await fiber.dispose();
|
||||
|
||||
@@ -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: 7fc0707debb1e67c
|
||||
zh: e3b5529bf82f868e
|
||||
/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 final `RECAP` line carrying the verdicts, changes, verifications, refusals and scans, and a summary with the counts, recovery commands and `nextAction`; unknown observations remain unknown. The same run writes every record to `-Out\acl-report-*.jsonl`, because tool output keeps only its tail. `-Out` also receives two recovery files per change: the DACL the object owned before that change and the independent script that restores it. Action completion records API execution; verification records the observed result. The [recovery decisions](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md#acl-diagnosis-and-recovery) explain resource ownership and rollback limits.
|
||||
|
||||
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 记录,最后给出一行 `RECAP`(判定、改动、验证、拒绝与扫描结果)以及含计数、恢复命令和 `nextAction` 的摘要;未知观察保持未知。同一次执行还会把全部记录写入 `-Out\acl-report-*.jsonl`,因为工具输出只保留末尾部分。`-Out` 同时为每次改动写入两个恢复文件:该对象在本次改动前拥有的 DACL,以及用于恢复它的独立脚本。操作完成记录 API 执行情况,验证记录实际观察到的结果。[恢复决策](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md#acl-diagnosis-and-recovery) 说明资源归属和回滚限制。
|
||||
|
||||
检查会从请求对象逐级访问祖先,直到文件系统根目录。每条 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,63 @@
|
||||
---
|
||||
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 later backup alone cannot undo earlier repairs.
|
||||
**Write every approval request in plain words, in the user's language.** The prompt is all the user reads before widening access, so it must stand alone: which folder the script touches, that it adds the signed-in user's full-control entry where a right is missing and removes foreign package entries, that file contents and owners are unchanged, and that the printed recovery command undoes each change. Keep error codes, `WRITE_DAC`/`WRITE_OWNER`, `S-1-15-2-*` SIDs, `icacls`, ACL, ACE, verdict names and switches out of the request: write Windows file permissions, not ACL or ACE. Ask once, for one command.
|
||||
|
||||
## Decide whether diagnosis applies
|
||||
## One command diagnoses and repairs
|
||||
|
||||
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.
|
||||
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;
|
||||
- a directory the sandbox cannot provision — the state its provisioning error reports on the workspace root — or the authorized root itself also has its subtree searched for those ACEs in the same run, so a deeper entry needs no second request. That bounded walk reports `truncated` and unreadable directories; if truncated, pass the still-failing deeper path once more.
|
||||
|
||||
## 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`.
|
||||
Every change is backed up, then verified by re-reading it. `-AllowRoot` bounds all of it: an object is changed only when it is that directory or strictly inside it, so a workspace root can repair itself. Never split this into a diagnostic call and a repair call, never ask twice for one repair, and never pass a mode switch that does not exist.
|
||||
|
||||
```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 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. Keep `-Out` persistent and user-owned, preferably beside the failing workspace; never use the skill resource directory, which is deleted when the skill unloads. Pass one path per invocation.
|
||||
|
||||
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.
|
||||
## Run it
|
||||
|
||||
| Verdict | Next step, only with complete observations |
|
||||
Repeat the failing operation once confined. The script writes permissions, which the confined token cannot do — run it there and it would report the sandbox's own restriction as a missing right — so request approval for that one command and run it unconfined. A confined run of the script is never useful. Unconfined does not elevate the Windows token.
|
||||
|
||||
Diagnose unexpected denials of workspace writes, listing, or plainly readable paths; explain expected ones instead: writes outside the workspace, any write in `read-only`, piped grandchild `spawn EPERM`, ConstrainedLanguage errors. If approval is refused or unavailable, report the path as undiagnosed and stop.
|
||||
|
||||
## Read the output
|
||||
|
||||
Every record reaches stdout, the `acl-report-*.jsonl` file under `-Out`, and the final `RECAP` line, which carries the verdicts, changes, verifications, refusals and scans. Tool output keeps only its tail, so read the recap first, and read specific records from the report when it is not enough. Trust `verification` records, never `completed` actions. Decide from `details.nextAction`:
|
||||
|
||||
| `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 repaired, or a refusal ended the run. Report and stop. |
|
||||
| `restore_pending_then_stop` | Rollback unverified. Run the printed recovery commands in order, then stop. |
|
||||
|
||||
## Run the selected repair
|
||||
A deny ACE's presence alone does not establish causation, and the script never removes one; DSH's `S-1-4-*` grants and the Everyone `DeleteSubdirectoriesAndFiles` deny are expected, not conflicts. A deny that blocks the repair ends the run without a repair (`REPAIR_REFUSED`, or `GRANT_FAILED`) after restoring what it attempted. Each change leaves two files in `-Out` (`acl-backup-<id>.json` and its `.ps1`), and the run prints the matching `ROLLBACK` command.
|
||||
|
||||
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.
|
||||
## After a repair, and when it stops
|
||||
|
||||
Append `-AllowRoot '<authorized-containing-directory>'` and **exactly one** of `-Fix` or `-GrantFullControl` to the diagnostic command, before `exit`. Keep `-Compact -Out`. Every explicit source must be strictly inside that root; reparse paths and managed application trees (`LocalAppData\Packages`, `ProgramFiles\WindowsApps`) are refused. Never widen the authorized root to reach an ancestor. Missing effective `WRITE_DAC` in the unconfined caller requires stopping and reporting the path and missing rights for permission-policy review. Extracted scripts and recovery copies are user-writable: never supply commands to run them elevated, initiate UAC/`runas`, or run the whole agent elevated.
|
||||
Repeat the original failed operation confined. A verified repair is not undone because the original operation fails for a further reason: continue from the new observations, and if a deeper path is still denied, run the same one command there — again one call, one approval.
|
||||
|
||||
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.
|
||||
**Stop after any failed or refused repair, or failed verification.** The script already restored that invocation's changes; do not repeat it or start another repair, and never remove a deny ACE by hand.
|
||||
|
||||
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.
|
||||
**A stopped run still owes the user a decision.** Name the blocking object and the right or ACE it lacks, what changed and what was rolled back, the recovery commands in order, and the report path. Only the user can lift confinement, so ask them to switch this session to **full access** (Chinese UI: 完全权限) temporarily, and say what that opens: keep working under it, or ask you to keep investigating this file-permission problem, since the report already records the mechanism and the evidence. Then ask them to send this session as feedback, quoting the report records that matter, so the unhandled scenario reaches us — only what reaches this conversation travels with it. For example:
|
||||
|
||||
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.
|
||||
> 这个工作区被 Windows 文件权限挡住了:`<对象>` 上 `<缺哪个权限 / 哪条权限项>`。你可以把本会话切到「完全权限」(full access) 临时继续工作;切完之后既能直接干活,也可以让我继续排查这个文件权限问题(报告里已经有机制和证据)。也麻烦把这个会话作为反馈发出去,好让我们补上这个场景。
|
||||
>
|
||||
> This workspace is blocked by Windows file permissions: `<object>` `<missing right / offending entry>`. Switch this session to full access (完全权限) to keep working meanwhile; then either continue your work or have me keep investigating this file-permission problem from the report. Please also send this session as feedback so we can cover the case.
|
||||
|
||||
## Never
|
||||
|
||||
- edit ACLs by hand, change an owner, erase a deny, or replace child permissions recursively;
|
||||
- run the script elevated, through UAC or `runas`;
|
||||
- widen `-AllowRoot` to reach an ancestor, or repeat a denied or failed call.
|
||||
|
||||
Report in the user's language: analyzed paths, changes, verification, recovery commands, next step. Label an authorized unconfined run as such, not as a sandbox repair.
|
||||
+309
-232
@@ -1,75 +1,66 @@
|
||||
<#
|
||||
.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; the authorized root
|
||||
is examined for the same reason. For either 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 and unreadable directories instead of claiming a complete walk.
|
||||
|
||||
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.
|
||||
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 change, so an object repaired twice keeps both recovery points.
|
||||
|
||||
.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
|
||||
@@ -78,30 +69,34 @@ $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++ }
|
||||
$script:reports.Add($record)
|
||||
$json = $record | ConvertTo-Json -Depth 12 -Compress
|
||||
if ($script:reportWriter) {
|
||||
# The complete record set also lands in the report file: tool output is truncated to its
|
||||
# tail, so a long run's early records survive only there.
|
||||
if ($null -ne $script:reportWriter) {
|
||||
try { $script:reportWriter.WriteLine($json); $script:reportWriter.Flush() }
|
||||
catch {
|
||||
$script:reportWriter.Dispose()
|
||||
catch [System.IO.IOException] {
|
||||
# stdout keeps only its tail, so this file is the complete record: a write failure
|
||||
# has to reach the caller, on stderr, instead of leaving a silently incomplete file.
|
||||
[Console]::Error.WriteLine('REPORT_IO_FAILED {0}: {1}' -f $script:reportPath, $_.Exception.Message)
|
||||
try { $script:reportWriter.Dispose() } catch { }
|
||||
$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' }
|
||||
@@ -257,15 +252,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 +274,68 @@ 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
|
||||
$failed = 0
|
||||
$truncated = $false
|
||||
$queue = [System.Collections.Generic.Queue[string]]::new()
|
||||
# Unreadable directories are skipped, never guessed at: the caller reports the count.
|
||||
try {
|
||||
foreach ($child in [System.IO.Directory]::EnumerateDirectories($Root)) {
|
||||
if ($enqueued -ge $Limit) { $truncated = $true; break }
|
||||
$queue.Enqueue($child); $enqueued++
|
||||
}
|
||||
} catch [System.UnauthorizedAccessException] { $failed++ }
|
||||
catch [System.IO.IOException] { $failed++ }
|
||||
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) { $failed++; 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 [System.UnauthorizedAccessException] { $failed++ }
|
||||
catch [System.IO.IOException] { $failed++ }
|
||||
try {
|
||||
foreach ($child in [System.IO.Directory]::EnumerateDirectories($current)) {
|
||||
if ($enqueued -ge $Limit) { $truncated = $true; break }
|
||||
$queue.Enqueue($child); $enqueued++
|
||||
}
|
||||
} catch [System.UnauthorizedAccessException] { $failed++ }
|
||||
catch [System.IO.IOException] { $failed++ }
|
||||
}
|
||||
return [ordered]@{ sources = @($sources); visited = $visited; truncated = $truncated; failed = $failed }
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -286,31 +344,32 @@ 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")" }
|
||||
@{ 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')
|
||||
}
|
||||
$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
|
||||
@@ -322,46 +381,25 @@ 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)
|
||||
}
|
||||
|
||||
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)
|
||||
param([string]$FullPath, [string]$MeSid, [switch]$CompactRecord)
|
||||
$facts = [ordered]@{
|
||||
Object = $FullPath
|
||||
Readable = $false
|
||||
@@ -429,7 +467,10 @@ function Get-ObjectFacts {
|
||||
}
|
||||
Write-Report observation inspect_acl $FullPath $(if ($facts.Errors.Count) { 'partial' } else { 'read' }) 'Read the ACL and check effective WRITE_DAC and WRITE_OWNER; observed ACEs alone do not identify which rule caused a denial.' @{
|
||||
owner = $facts.Owner; writeDac = $facts.HasWriteDac; writeOwner = $facts.HasWriteOwner
|
||||
aces = $facts.Aces; nativeListing = @($facts.AclLines | ForEach-Object { $_.Trim() } | Where-Object { $_ }); lowLabel = $facts.LowLabel; errors = $facts.Errors
|
||||
# A re-read after a change repeats an ACL this run already recorded: keep the
|
||||
# full ACE list in memory, but do not print it twice.
|
||||
aces = $(if ($CompactRecord) { @() } else { $facts.Aces }); acesOmitted = [bool]$CompactRecord
|
||||
lowLabel = $facts.LowLabel; errors = $facts.Errors
|
||||
}
|
||||
return $facts
|
||||
}
|
||||
@@ -452,9 +493,8 @@ 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
|
||||
$reportWriter = $null
|
||||
$requestedPaths = @()
|
||||
$rollbackStatus = 'not-needed'
|
||||
$fixed = 0
|
||||
@@ -463,17 +503,20 @@ $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
|
||||
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 ($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 {
|
||||
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') }
|
||||
if ($Out) {
|
||||
Invoke-ReportedOperation prepare_report $Out 'Create the recovery directory and a new JSONL report that keeps every record, because tool output is truncated to its tail.' files {
|
||||
$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'))
|
||||
@@ -488,20 +531,6 @@ try {
|
||||
$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
|
||||
@@ -511,23 +540,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
|
||||
@@ -538,11 +571,32 @@ try {
|
||||
$needsPrecondition = (-not $unreadable) -and
|
||||
((-not $targetFacts.HasWriteDac) -or (-not $targetFacts.HasWriteOwner))
|
||||
|
||||
# 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 before the verdict so the classification and its packageObjects describe what
|
||||
# this run will actually repair.
|
||||
$ownsRoot = $full -ieq (Get-NormalizedPath $AllowRoot)
|
||||
if (-not $Restore -and $targetFacts.Errors.Count -eq 0 -and [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 $(if ($scan.failed -gt 0) { 'partial' } else { 'read' }) 'The caller cannot provision this directory, or it is the authorized root, so explicit package allow ACEs under it were collected in the same run; no reparse point or managed application tree is entered.' @{
|
||||
visited = $scan.visited; packageSources = @($scan.sources); truncated = [bool]$scan.truncated; unreadable = [int]$scan.failed
|
||||
}
|
||||
Write-Line ('SCAN {0} VISITED={1} PACKAGE_SOURCES={2}{3}{4}' -f $full, $scan.visited, @($scan.sources).Count, $(if ($scan.truncated) { ' TRUNCATED' } else { '' }), $(if ($scan.failed -gt 0) { " UNREADABLE=$($scan.failed)" } else { '' }))
|
||||
if ($scan.truncated) { $script:scanTruncated = $true }
|
||||
}
|
||||
|
||||
if ($targetFacts.Errors.Count -gt 0) {
|
||||
$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) {
|
||||
@@ -556,133 +610,138 @@ 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 }
|
||||
$refusal = Get-RepairRefusal -FullPath $full -Root $AllowRoot
|
||||
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
|
||||
# Nothing is changed while a package source or a grant target is out of reach: a
|
||||
# repair that cannot finish would leave the tree half repaired before refusing.
|
||||
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 $factsByPath[$_].Errors.Count -eq 0 -and
|
||||
((-not $factsByPath[$_].HasWriteDac) -or (-not $factsByPath[$_].HasWriteOwner)) -and
|
||||
-not (Get-RepairRefusal -FullPath $_ -Root $AllowRoot -AllowEqual)
|
||||
})
|
||||
# Writing a DACL needs WRITE_DAC: refuse a target that lacks it before anything is
|
||||
# changed, instead of failing mid-repair and marking a recovery for an object that
|
||||
# the rollback then cannot restore either.
|
||||
foreach ($object in $grantTargets) {
|
||||
if (-not $factsByPath[$object].HasWriteDac) {
|
||||
Write-Line ('REPAIR_REFUSED {0} needs WRITE_DAC; stop for permission-policy review' -f $object)
|
||||
Write-Decision $mode $object refused 'Effective WRITE_DAC is absent; adding the grant requires that right.'
|
||||
$refused++
|
||||
break paths
|
||||
}
|
||||
}
|
||||
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))
|
||||
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 -CompactRecord
|
||||
$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.'
|
||||
# Ancestor first, for chain and scan-discovered sources alike.
|
||||
$sources = @($sources | Sort-Object -Property @{ Expression = { $_.Split([char]92).Count } }, @{ Expression = { $_ } })
|
||||
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, leaving inheritance enabled so inherited copies follow their repaired source.' @{
|
||||
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) {
|
||||
$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))
|
||||
$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.' @{
|
||||
remainingPackageAces = $after.PackageAces; otherEntriesUnchanged = $collateral
|
||||
|
||||
# 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 -CompactRecord
|
||||
# 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 }
|
||||
$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 }
|
||||
@@ -707,15 +766,33 @@ 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' }
|
||||
Write-Line ('SUMMARY FIXED={0} GRANTED={1} REFUSED={2} RESTORED={3}' -f $fixed, $granted, $refused, $restored)
|
||||
$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' }
|
||||
# The tool keeps only the tail of stdout. Recapitulate the decisions here so the
|
||||
# records a reader needs survive truncation; the report file holds every record.
|
||||
$recap = [ordered]@{
|
||||
verdicts = @($reports | Where-Object { $_.operation -eq 'classify' } | ForEach-Object {
|
||||
@{ path = $_.path; verdict = $_.status; writeDac = $_.details['writeDac']; writeOwner = $_.details['writeOwner']; packageObjects = $_.details['packageObjects'] }
|
||||
})
|
||||
changes = @($reports | Where-Object { $_.kind -eq 'action' -and $_.details['effect'] -eq 'acl' -and $_.status -eq 'completed' } | ForEach-Object { @{ operation = $_.operation; path = $_.path } })
|
||||
verifications = @($reports | Where-Object { $_.kind -eq 'verification' } | ForEach-Object { @{ operation = $_.operation; path = $_.path; status = $_.status } })
|
||||
refusals = @($reports | Where-Object { $_.kind -eq 'decision' -and $_.status -eq 'refused' } | ForEach-Object { @{ path = $_.path; reason = $_.reason } })
|
||||
scans = @($reports | Where-Object { $_.operation -eq 'subtree_scan' } | ForEach-Object {
|
||||
@{ path = $_.path; visited = $_.details['visited']; packageSources = @($_.details['packageSources']); truncated = $_.details['truncated']; unreadable = $_.details['unreadable'] }
|
||||
})
|
||||
report = $reportPath
|
||||
}
|
||||
$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; report = $reportPath
|
||||
}
|
||||
# Last on stdout, after the summary record: whatever the tool truncates, the tail
|
||||
# still carries the decisions, the report path and the counts.
|
||||
Write-Line ('RECAP ' + ($recap | ConvertTo-Json -Depth 8 -Compress))
|
||||
if ($reportPath) { Write-Line ('REPORT_FILE {0}' -f $reportPath) }
|
||||
Write-Line ('SUMMARY FIXED={0} GRANTED={1} REFUSED={2} RESTORED={3}' -f $fixed, $granted, $refused, $restored)
|
||||
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 observed DACL with the recovery record after writing it.
|
||||
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'))
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Load the diagnose-windows-sandbox-acl skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n<available_skills>\n- `diagnose-windows-sandbox-acl`: 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.\n</available_skills>\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n</system-reminder>"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"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."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n<available_skills>\n- `diagnose-windows-sandbox-acl`: 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.\n</available_skills>\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n</system-reminder>"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"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. 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."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
{"type":"session/title","data":{"title":"Load the diagnose-windows-sandbox-acl sk","messageSeqs":[8],"source":{"kind":"fallback"}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"diagnose-windows-sandbox-acl\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5},"stream":[{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430688,"index":0,"dt":[],"texts":["Load the requested skill."]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699430688,"index":1,"dt":[],"id":"call_skill_load","name":"skill","args":["{\"name\":\"diagnose-windows-sandbox-acl\"}"]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"diagnose-windows-sandbox-acl\"}"}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"diagnose-windows-sandbox-acl\"}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_skill_load"},"toolCallId":"call_skill_load","content":[{"type":"text","text":"<skill_content name=\"diagnose-windows-sandbox-acl\">\n<skill_resources>\nBase directory for this skill: {{cwd}}/.dsh/skills/diagnose-windows-sandbox-acl\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n</skill_resources>\n\n<skill_instructions>\n# Diagnose Windows sandbox ACL failures\n\n**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.\n\n## Decide whether diagnosis applies\n\nDiagnose 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.\n\nStop 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.\n\n## Read the report before choosing a repair\n\nResolve 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`.\n\n```powershell\n& '<skill-directory>\\scripts\\diagnose-windows-sandbox-acl.ps1' -Path '<failing-path>' -Out '<report-directory>' -Compact\nexit $LASTEXITCODE\n```\n\nSubstitute 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.\n\nEach 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.\n\n| Verdict | Next step, only with complete observations |\n|---|---|\n| `CULPRIT` | Explain the affected paths and individual package allow SIDs; select `-Fix`. |\n| `PRECONDITION` | Explain missing effective `WRITE_DAC`/`WRITE_OWNER`; select `-GrantFullControl` if the caller has `WRITE_DAC`. |\n| `BOTH` | Grant on the affected object first. Only after verified success may a separate invocation use `-Fix`. Failure ends this sequence. |\n| `UNREADABLE`, `INCOMPLETE`, `NOT_THIS_CLASS` | Report observations and stop; do not infer a safe repair. |\n\n## Run the selected repair\n\nExplain 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 `WRITELine truncated
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_skill_load"},"toolCallId":"call_skill_load","content":[{"type":"text","text":"<skill_content name=\"diagnose-windows-sandbox-acl\">\n<skill_resources>\nBase directory for this skill: {{cwd}}/.dsh/skills/diagnose-windows-sandbox-acl\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n</skill_resources>\n\n<skill_instructions>\n# Diagnose Windows sandbox ACL failures\n\n**Write every approval request in plain words, in the user's language.** The prompt is all the user reads before widening access, so it must stand alone: which folder the script touches, that it adds the signed-in user's full-control entry where a right is missing and removes foreign package entries, that file contents and owners are unchanged, and that the printed recovery command undoes each change. Keep error codes, `WRITE_DAC`/`WRITE_OWNER`, `S-1-15-2-*` SIDs, `icacls`, ACL, ACE, verdict names and switches out of the request: write Windows file permissions, not ACL or ACE. Ask once, for one command.\n\n## One command diagnoses and repairs\n\nThe script has no modes. `-Path`, `-AllowRoot` and `-Out` read the path and every ancestor and repair what the observations prove, in the same run:\n\n- 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;\n- 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;\n- a directory the sandbox cannot provision — the state its provisioning error reports on the workspace root — or the authorized root itself also has its subtree searched for those ACEs in the same run, so a deeper entry needs no second request. That bounded walk reports `truncated` and unreadable directories; if truncated, pass the still-failing deeper path once more.\n\nEvery change is backed up, then verified by re-reading it. `-AllowRoot` bounds all of it: an object is changed only when it is that directory or strictly inside it, so a workspace root can repair itself. Never split this into a diagnostic call and a repair call, never ask twice for one repair, and never pass a mode switch that does not exist.\n\n```powershell\n& '<skill-directory>\\scripts\\diagnose-windows-sandbox-acl.ps1' -Path '<failing-path>' -AllowRoot '<authorized-directory>' -Out '<recovery-directory>'\nexit $LASTEXITCODE\n```\n\nSubstitute full, quoted paths. Keep `-Out` persistent and user-owned, preferably beside the failing workspace; never use the skill resource directory, which is deleted when the skill unloads. Pass one path per invocation.\n\n## Run it\n\nRepeat the failing operation once confined. The script writes permissions, which the confined token cannot do — run it there and it would report the sandbox's own restriction as a missing right — so request approval for that one command and run it unconfined. A confined run of the script is never useful. Unconfined does not elevate the Windows token.\n\nDiagnose unexpected denials of workspace writes, listing, or plainly readable paths; explain expected ones instead: writes outside the workspace, any write in `read-only`, piped grandchild `spawn EPERM`, ConstrainedLanguage errors. If approval is refused or unavailable, report the path as undiagnosed and stop.\n\n## Read the output\n\nEvery record reaches stdout, the `acl-report-*.jsonl` file under `-Out`, and the final `RECAP` line, which carries the verdicts, changes, verifications, refusals and scans. Tool output keeps only its tail, so read the recap first, and read specific records from the report when it is not enough. Trust `verification` records, never `completed` actions. Decide from `details.nextAction`:\n\n| `nextAction` | Meaning |\n|---|---|\n| `verify_original_confined_operation` | Repairs verified; repeat the original operation confined. |\n| `stop` | Nothing repaired, or a refusal ended the run. Report and stop. |\n| `restore_pending_then_stop` | Rollback unverified. Run the printed recovery commands in order, then stop. |\n\nA deny ACE's presence alone does not establish causation, and the script never removes one; DSH's `S-1-4-*` grants and the Everyone `DeleteSubdirectoriesAndFiles` deny are expected, not conflicts. A deny that blocks the repair ends the run without a repair (`REPAIR_REFUSED`, or `GRANT_FAILED`) after restoring what it attempted. Each change leaves two files in `-Out` (`acl-backup-<id>.json` and its `.ps1`), and the run prints the matching `ROLLBACK` command.\n\n## After a repair, and when it stops\n\nRepeat the original failed operation confined. A verified repair is not undone because the original operation fails for a further reason: continue fromLine truncated
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4},"stream":[{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430709,"index":0,"dt":[],"texts":["The skill is loaded."]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699430709,"index":1,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
|
||||
|
||||
Reference in new issue
Block a user