summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md35
-rw-r--r--doc/AuditReport.md12
-rw-r--r--doc/Design.md19
-rw-r--r--plans/RequirementTraceability.md17
-rw-r--r--scripts/Verify-PublishedHttp.ps187
-rw-r--r--scripts/Verify-Readme.ps1258
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs16
-rw-r--r--tests/Agent.Windows.Tests/ReadmeOperationalTests.cs446
-rw-r--r--tests/Agent.Windows.Tests/SecurityPolicyTests.cs77
9 files changed, 954 insertions, 13 deletions
diff --git a/README.md b/README.md
index 64f5b55..e4e286b 100644
--- a/README.md
+++ b/README.md
@@ -173,6 +173,41 @@ Mountの`access`には`read_only`、`read_write`、`read_write_delete`を指定ã
承認は最初にBrokerが直接起動するEntry Processにだけ適用されます。Entry Processから生成される子孫Processには個別承認を要求しませんが、同じLPAC、ACL、WFP、Job Objectの制限を継承します。未知の外部ExecutableやMount上のExecutableは、Brokerから直接起動できません。
+#### cmd.exeを有効にする例
+
+`agent.local.yaml`のShell一覧へAliasを追加します。配列はlocal側で置換されるため、ほかのShellも使う場合はすべて列挙してください。
+
+```yaml
+sandbox:
+ profile: development
+ workspaces:
+ - C:/tmp
+ execution:
+ executables:
+ - python
+ shells:
+ - cmd
+```
+
+管理者として起動したPowerShellで、cmdの実体を確認して承認します。cmdはWindowsのOS Runtime Rootを利用するため、追加Runtime Rootを登録しない`--no-runtime`を指定します。
+
+```powershell
+cd <install-dir>
+.\agent-mcp.exe executable resolve cmd `
+ --path C:\Windows\System32\cmd.exe `
+ --config .\agent.yaml
+.\agent-mcp.exe executable approve cmd `
+ --path C:\Windows\System32\cmd.exe `
+ --no-runtime `
+ --config .\agent.yaml
+.\agent-mcp.exe executable list --config .\agent.yaml
+.\agent-mcp.exe check --config .\agent.yaml --lang ja
+```
+
+非対話で承認する場合だけ`approve`へ`--yes`を付けます。この操作はAuditへ記録されます。
+
+cmdから子Processを呼ぶ場合は、`cmd.exe`、`curl.exe`、`.venv\Scripts\python.exe`のようにPATHで解決できる名前またはWorkspace相対パスを使います。`%ComSpec%`や`%SystemRoot%\System32\curl.exe`のようなドライブ修飾絶対パスは、cmd自身が事前にvolume情報へアクセスするため、LPACではOSに拒否されることがあります。これは子Process生成の禁止ではありません。
+
### Network
| `access` | 許可内容 |
diff --git a/doc/AuditReport.md b/doc/AuditReport.md
index 47ee218..b176260 100644
--- a/doc/AuditReport.md
+++ b/doc/AuditReport.md
@@ -12,6 +12,18 @@ commit `37386b8`に対する2026-08-11の再監査結果は履歴として以下
- Entry ProcessだけをApproval対象とし、Descendant Processは個別Approvalなしで同じLPAC identity、ACL、WFP、Job confinementを継承する。
- 2026-08-12の最終working-tree検証でRelease buildはwarning 0/error 0、Core 103件、Planner 10件、Windows Security/Recovery 53件がすべてPASS、skip 0。`deepseek-v4-flash:cloud` + `think:true` + Python 3.14.3によるstdio、HTTP IPv4、HTTP IPv6 Operational E2EもすべてPASSし、各経路で複雑なPython実装、子Python、独立unittestを完走した。Task終了後のJournal、backup、published process残留は0。証跡は`artifacts/evidence/sandbox-usability-final-working-tree-2/`に保存した。
+## 2026-08-13 LPAC Descendant互換性再監査
+
+`cmd.exe`から`curl.exe`を起動できない事象をEntry×Descendantで再調査した。最初のmatrixは`%ComSpec%`や`%SystemRoot%\System32\curl.exe`などドライブ修飾絶対pathを使用しており、その失敗をcmdのDescendant生成拒否と誤認していた。追加調査では、LPAC上のcmdから`cmd.exe /d /c exit 0`と`curl.exe --version`が成功した。絶対pathの場合はcmdが子Process生成前に`GetVolumeInformationW("C:\")`を呼び、そのアクセスが拒否されていた。したがって、cmdおよびLPAC/no-breakaway Process Tree一般の非互換性ではない。
+
+MicrosoftのAppContainer資料は、unpackaged Win32 processをAppContainerで起動する方法と子のtoken継承を説明するが、任意のWin32 Executable組合せの動作互換性までは保証していない。LPACは`ALL APPLICATION PACKAGES`由来accessをopt-outするため、必要capabilityを明示選定する必要がある([Launch an AppContainer](https://learn.microsoft.com/en-us/windows/win32/secauthz/implementing-an-appcontainer)、[AppContainer for legacy applications](https://learn.microsoft.com/en-us/windows/win32/secauthz/appcontainer-for-legacy-applications-))。またJob Objectではbreakawayを許可しない構成がProcess Tree監視を維持する方式であり、breakawayを許可すると子がJob外になる([Job Objects](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects)、[Process Creation Flags](https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags))。
+
+`lpacAppExperience`はMicrosoft Learn/Windows SDKで権限範囲を確認できないため採用しない。限定実験の変更も撤回した。Job breakaway、通常AppContainer fallback、System32 protected DACL解除はいずれもSecurity Boundaryを弱めるため採用していない。
+
+是正として、誤ったcmd専用エラー分類と失敗を期待するtestを撤回し、protected DACLとの両立規則、capability採用基準、Entry×Descendant×System32 utilityの組合せmatrixをDesignへ追加した。cmdのtestはPATH解決名またはWorkspace相対pathで実際のDescendant起動とNetwork通信を検証する。また、HTTP MCP実動作でSystem32 rootを除外しても配下のApproved `cmd.exe` fileへ個別ACLを適用していた実装漏れを検出したため、OS Runtime Root配下全体をDACL変更対象外とした。Network capabilityなし/ありの両条件ではRegistry read、Registry write拒否、COM拒否、Broker secret非継承も組合せ検証した。
+
+是正後はREADME/CLI/Windows operational suite 19件とAgent.Windows全75件が管理者・skip 0でPASSした。最新publish BundleのHTTP MCP `agent_run`もcompleted 3/3となり、cmdから非修飾`cmd.exe`、非修飾`curl.exe`、Workspace相対`C:\tmp\.venv` Pythonを起動し、Network `none`の遮断と`CMD_HTTP_MCP_OK`を確認した。全9組の子Process自身によるLPAC SID/Job個別記録は未実施のため、対応するTraceability項目だけはPARTIALとして残す。
+
## 確認した矛盾と是正
| 観点 | 監査時の状態 | 是正 |
diff --git a/doc/Design.md b/doc/Design.md
index 8f76ca6..d4920a0 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -557,6 +557,18 @@ network:
- standard `CreateProcess`の`PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES`に加え、`PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY=PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT`を指定してLPAC tokenを生成する。
- Experimental CreateProcessInSandbox、AppLocker、WDACへ依存しない。LPAC生成または互換性検査失敗時に通常AppContainerへfallbackしない。
- baseline capabilityは`registryRead`と、PowerShell Runtimeが起動時のETW provider登録に必要とする`lpacInstrumentation`とする。Registry writeと`lpacCom`は付与しない。Network capabilityはEffective Network Policyが必要な場合だけ付与する。`lpacInstrumentation`追加後もRegistry writeとCOM activationの実拒否をWindows Security suiteで検証する。
+- capabilityはExecutable互換性の一般的なスイッチとして追加しない。採用には、(1) Microsoft公開資料またはWindows SDKで意味とSecurity Boundaryが確認できること、(2) 必要な正方向ケースが追加前に失敗し追加後に成功すること、(3) Filesystem/Registry/COM/Network/Process Treeの負方向matrixが追加後も成功すること、(4) 名称、SID、由来、適用条件をAuditへ記録できること、をすべて要求する。意味が公開されていないcapabilityは実験結果だけでbaselineへ昇格させない。
+- `registryRead`は全Profileで必要なRegistry readだけに使用する。`lpacInstrumentation`はPowerShell起動互換性のための既存baselineであり、公開された意味の不足を負方向testで補う暫定互換capabilityとして扱う。`internetClient`/`privateNetworkClientServer` SIDはNetworkが`local|restricted`の場合だけ付与し、到達先はWFP hard permitでさらに制限する。`lpacCom`はv1で禁止する。`lpacAppExperience`は公開された権限範囲を確認できないため採用しない。
+
+| capability | 根拠と用途 | 適用条件 | 必須の負方向検証 |
+| --- | --- | --- | --- |
+| `registryRead` | MicrosoftのLPAC起動手順に従い明示SIDとして付与し、Windows/runtimeのreadに限定 | 全Profileの固定baseline | Registry write、Broker credential環境の非継承 |
+| `lpacInstrumentation` | PowerShellのETW provider登録失敗を解消した実測根拠。公開された権限範囲が不足するため暫定扱い | 全Profileの固定baseline。削除可能性を継続評価 | Registry write、COM activation、Policy外Filesystem/Network |
+| `internetClient` / `privateNetworkClientServer` | Windows capability SID。WFPだけではLPAC token側のnetwork gateを通らないため使用 | Effective Network Policyが`local|restricted`のTaskだけ | `none`全deny、Policy外address/port deny、終了後WFP残留0 |
+| `lpacCom` | v1がCOMを明示deny | 付与禁止 | COM activation拒否 |
+| `lpacAppExperience` | 公開された権限範囲を確認できない | 付与禁止 | capability catalog/auditに出現しないこと |
+
+根拠資料はMicrosoftの[Launch an AppContainer](https://learn.microsoft.com/en-us/windows/win32/secauthz/implementing-an-appcontainer)、[AppContainer for legacy applications](https://learn.microsoft.com/en-us/windows/win32/secauthz/appcontainer-for-legacy-applications-)、[UpdateProcThreadAttribute](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute)を正本とする。名称からSIDを導出できることだけを権限範囲の根拠にしてはならない。
- AppContainer外の通常user tokenに戻るbreakawayを許可しない。DescendantはLPAC tokenを継承する。
- `CreateProcess`へBrokerのenvironment blockをそのまま継承しない。Windows/runtime起動に必要なsystem/profile path、`ComSpec`、System32だけのPATH、Task `cwd`を指すTEMP/TMPと`DOTNET_CLI_HOME`、非対話かつnetwork非依存の.NET初回起動・telemetry・workload resolver値、Process Tree外の共有MSBuild server/node reuseを無効化する値から成る明示的なUnicode environment blockを渡し、任意のtoken、password、SSH/API credential環境変数を除外する。profile path自体はFilesystem Capabilityを付与しない。
### 11.2 NTFS ACL
@@ -565,7 +577,9 @@ network:
- development Workspace/Artifact Rootはread/write/delete/execute、Runtime RootとResolved `%SystemRoot%/System32`はread/execute、Mountは指定accessのみを取得する。
- 許可pathがdirectoryの場合、rootへのinheritance ACEで既存およびTask中に作成されたdescendantを覆い、descendantごとのDACL列挙は行わない。
- 許可pathがfileの場合は、そのfileだけへ継承なしACEを追加する。異なる権限を持つnested rootは別scopeとして逆順cleanupする。
-- protected DACLを持つdescendantの継承保護は解除しない。そのpathはOS拒否のままとし、Sandbox権限を暗黙に拡張しない。
+- protected DACLを持つrootまたはdescendantの継承保護は解除せず、所有権変更やDACL全体の置換も行わない。Application SID ACEをそのobjectへ安全に追加できない場合はOS拒否を維持してfail-closedにする。
+- OS Runtime Root (`%SystemRoot%/System32`) はOSが管理するprotected DACLとの両立を優先し、BrokerがApplication SID ACEを追加しない。OS Runtime Root配下のApproved Entry Executable fileにも個別ACEを追加しない。LPACから観測される既存のOS accessだけを利用し、Effective Sandbox Policyの`os_runtime_root: read/execute`はDACL変更命令ではなく許容上限を表す。実際のExecutable互換性はEntry/Descendant組合せtestで確認する。
+- Workspace、Mount、Runtime Rootではrootの現在のSecurity DescriptorをJournalへ保存した後、protected状態を維持したまま追加ACEを適用できる場合だけleaseを成立させる。cleanup後は元Security Descriptorとのbyte-level一致を検証する。nested protected DACLがrootの継承ACEを遮る場合、そのobjectを自動走査して保護解除せず、必要accessが得られなければその組合せを拒否する。
- rootの元security descriptorをRecovery Journalへ1件記録する。
- Task終了で追加分を除去し、元状態との整合を検査する。
### 11.3 Job Object
@@ -767,6 +781,9 @@ closed Sessionは再利用しない。プロセス再起動後に以前のSessio
| ST-22 | non-loopback HTTP bind | 起動失敗 |
| ST-23 | Approval/Resolution Store | user-only DACL、atomic replace、破損record拒否 |
| ST-24 | config fingerprint/Resolved Path/hash変更 | 再確認または再承認までfail-closed |
+| ST-25 | Entry (`PowerShell`/Python/.NET/`cmd`) × Descendant (`cmd`/PowerShell/Python/.NET/System32 utility) | 個別Approvalなしで起動し、Descendantが同じLPAC SID/Jobに残る。cmd内からはPATH解決名またはWorkspace相対pathを使用する |
+| ST-26 | protected DACL root/descendantと通常DACL rootのread/write/execute | protected bitを解除せず、許可ケースだけ成功。適用不能ケースは拒否し、cleanup後Security Descriptorが完全一致 |
+| ST-27 | baseline/Network capabilityの追加前後 | 必要な正方向ケースに加えFilesystem、Registry write、COM、Policy外Network、Job breakawayの負方向集合をすべて検証 |
---
## 20. エラー分類
| Category | 例 | Task結果 | Retry |
diff --git a/plans/RequirementTraceability.md b/plans/RequirementTraceability.md
index 4f901e6..ae32290 100644
--- a/plans/RequirementTraceability.md
+++ b/plans/RequirementTraceability.md
@@ -2,7 +2,7 @@
Sandbox Usability Revision後の現行実装を対象とする。commit `37386b8`(2026-08-11 JST)以前の結果は`plans/progress/`と本書末尾に履歴として保持するが、現行PASS判定には使用しない。
-状態は`PASS / PARTIAL / FAIL / NOT REVERIFIED`を使う。2026-08-12の現行working-tree管理者証跡は`artifacts/evidence/sandbox-usability-final-working-tree-2/`に保存する。
+状態は`PASS / PARTIAL / FAIL / NOT REVERIFIED`を使う。2026-08-12の現行working-tree管理者証跡は`artifacts/evidence/sandbox-usability-final-working-tree-2/`、2026-08-13のLPAC Descendant再監査は`artifacts/evidence/lpac-descendant-reaudit-working-tree/`に保存する。
## Requirement mapping
@@ -16,7 +16,7 @@ Sandbox Usability Revision後の現行実装を対象とする。commit `37386b8
| PLN-01〜06 | `TaskGraph`, `PlannerService`, structural `task_update` | Planner 10件、structured-plan retry、Task固有Tool証跡test | PASS |
| SES-01〜06 | `SessionStore`、会話/graph/result保持、structured要約 | summary成功/失敗、session/runtime tests | PASS |
| FS-01〜10 | `ResolvedPath`, `PathPolicy`, `TaskAclManager`, `SandboxEnvironmentResolver` | Resolution/fingerprint/nested root、read/execute分離、readonly/development LPAC実効果、管理者suite | PASS |
-| PRC-01〜09 | `EntryExecutablePolicy`, `RestrictedProcessLauncher`, Approval Store、Job Process Tree | Alias-only Entry、hash、Runtime/Artifact分類、Entry/Descendant LPAC SID・Job、PowerShell/Python/.NET build/test、breakaway、process/memory/timeout | PASS |
+| PRC-01〜09 | `EntryExecutablePolicy`, `RestrictedProcessLauncher`, Approval Store、Job Process Tree | Alias-only Entry、hash、Runtime/Artifact分類、PowerShell/Python/.NET build/test、breakaway、process/memory/timeout、Entry×Descendant matrix | PASS — cmdを含む全9組をPATH解決名/Workspace相対pathで再検証し、publish済みHTTP MCPでもcmd→cmd/curl/venv Pythonを完走 |
| REC-01〜07 | retry、timeout、cancel cleanup、`RecoveryManager` | Core lifecycle、elevated crash/recovery matrix、残留0 | PASS |
| SEC-INV-01〜05 | deny-by-default、Policy非変更、Resolved Path/hash/Entry分類 | Core境界、管理者Windows suite、Operational E2E | PASS |
| SEC-INV-06〜08、13 | WFP none/local/restricted、credential非継承、Process Tree confinement | credential非継承、全Network mode、Process Tree実効果 | PASS |
@@ -33,7 +33,8 @@ Sandbox Usability Revision後の現行実装を対象とする。commit `37386b8
| ID | Current implementation / test | Status |
|---|---|---|
| AT-01〜06 | MCP stdio/HTTP、公開schema、Task lifecycle、Ollama、Vision拒否 | PASS |
-| AT-07〜13、21〜22 | Resolution、LPAC/ACL/Process Tree、WFP、crash recovery | PASS |
+| AT-07〜11、13、21〜22 | Resolution、LPAC/ACL/Process Tree、WFP、crash recovery | PASS |
+| AT-12 | Entry/DescendantのLPAC・Job confinement | PARTIAL — cmdを含む起動組合せは成功し、Python Descendant自身のSID/JobとProcess Tree制限は検証済み。全9組それぞれのDescendant自身によるSID/Job記録は未実施 |
| AT-14〜17 | cycle、immutable、PERT/CPM、全phase token集計 | PASS |
| AT-18〜20 | en/ja、追加locale、malformed/unknown locale | PASS |
@@ -42,10 +43,13 @@ Sandbox Usability Revision後の現行実装を対象とする。commit `37386b8
| ID | Current implementation / test | Status |
|---|---|---|
| ST-01〜09 | Profile/Mount/Resolution/Approval、ACL/LPAC実効果 | PASS |
-| ST-10〜14 | Descendant inheritance、Job timeout、WFP actual traffic | PASS |
+| ST-10〜14 | Descendant inheritance、Job timeout、WFP actual traffic | PASS — cmdを含むDescendant起動、Job timeout、PowerShell/cmd/PythonのNetwork none/local実通信を再実行 |
| ST-15〜16 | MCP非公開境界、Design Tool catalogの名前/schema/Policy境界 | PASS |
| ST-17〜18 | ACL/WFP/loopback crash recovery | PASS |
| ST-19〜20 | malformed locale fatal、non-loopback bind拒否 | PASS |
+| ST-25 | Entry×Descendant compatibility matrix | PARTIAL — PowerShell/cmd/venv Python親×cmd/curl/venv Python子の9組は全成功。全組合せで子自身のSID/Jobを個別記録する部分は未実施 |
+| ST-26 | protected DACL coexistence | PASS — System32のSecurity Descriptor非変更、protected descendant非上書き、通常root cleanup一致を実昇格確認 |
+| ST-27 | capability正負matrix | PASS — Network capabilityなし/ありの双方で固定baseline、Registry read、Registry write/COM拒否、secret非継承を確認し、Network到達性はnone/localのPowerShell/Python実通信で検証 |
Internal Tool testは件数を仕様としてassertしない。`Design.md`のTool catalogに記載された`file_search / file_read / file_edit / process_run / task_update / dir`の名前、schema、Policy境界、MCP非公開性を検査する。
@@ -57,6 +61,11 @@ Internal Tool testは件数を仕様としてassertしない。`Design.md`のToo
| Agent.Core.Tests | 2026-08-12 / administrator | 103 PASS、0 FAIL、0 skip |
| Agent.Planner.Tests | 2026-08-12 / administrator | 10 PASS、0 FAIL、0 skip |
| Agent.Windows.Tests | 2026-08-12 / administrator | 53 PASS、0 FAIL、0 skip。LPAC/ACL/Job/WFP/Recovery、PowerShell/Python/.NET Process Treeを含む |
+| LPAC compatibility initial diagnostic | 2026-08-13 / administrator / current working tree | cmd親へドライブ修飾絶対pathを渡したため3組が失敗。一般的なDescendant拒否という当時の判定は撤回し、現行PASS根拠には使用しない |
+| Agent.Windows.Tests initial diagnostic | 2026-08-13 / administrator / current working tree | 75件実行したが、cmd拒否を正解とした期待値を含むため現行のPRC/ST-10/ST-25受入証跡には使用しない |
+| README/CLI/Windows operational suite | 2026-08-13 / administrator / current working tree | 19 PASS、0 FAIL、0 skip。CLI、PowerShell/cmd/venv Python、Entry×Descendant 9組、Network none/local、ACL monkey 64反復 |
+| Agent.Windows.Tests corrected full suite | 2026-08-13 / administrator / current working tree | 75 PASS、0 FAIL、0 skip。System32/cmd.exe protected DACL非変更回帰を含む |
+| Published HTTP MCP cmd E2E | 2026-08-13 / administrator / published Bundle | `agent_run` completed 3/3。cmd→cmd/curl/C:\tmp\.venv Python、Network none、exit 0、`CMD_HTTP_MCP_OK`、Task終了後ACL復元を確認 |
| win-x64 self-contained Bundle | 2026-08-12 / administrator / fresh展開 | check PASS。既定ZIPにlocal override・local prompt/Skill・固有pathなし。deployment local資産保持。ZIP SHA-256 `f220cbf66d6d2bfd110542cdeec48cd64c97aec549b4ffd999c4746f3324ecb0` |
| Operational E2E | 2026-08-12 / administrator / `deepseek-v4-flash:cloud`, `think:true`, Python 3.14.3 | stdio、HTTP IPv4、HTTP IPv6すべてPASS。Python実装、子Python unittest、独立検証、ACL/WFP/temp cleanup確認 |
| Task Resource residual state | 2026-08-12 / administrator | Journal 0、backup 0、published `agent-mcp` process 0 |
diff --git a/scripts/Verify-PublishedHttp.ps1 b/scripts/Verify-PublishedHttp.ps1
new file mode 100644
index 0000000..a02a28b
--- /dev/null
+++ b/scripts/Verify-PublishedHttp.ps1
@@ -0,0 +1,87 @@
+param(
+ [string]$BundlePath = 'build\Release\publish\agent-sandbox-mcp-1.0.0-win-x64',
+ [string]$TestRoot = 'C:\tmp',
+ [string]$EvidenceRoot = 'artifacts\evidence\published-http-cmd-verification'
+)
+
+$ErrorActionPreference = 'Stop'
+$OutputEncoding = [Text.UTF8Encoding]::new($false)
+[Console]::OutputEncoding = $OutputEncoding
+$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+$bundle = [IO.Path]::GetFullPath((Join-Path $repository $BundlePath))
+$publishRoot = [IO.Path]::GetFullPath((Join-Path $repository 'build\Release\publish'))
+if (-not $bundle.StartsWith($publishRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'BundlePath must remain inside build\Release\publish.'
+}
+$testRootPath = (Resolve-Path -LiteralPath $TestRoot).Path
+$evidence = [IO.Path]::GetFullPath((Join-Path $repository $EvidenceRoot))
+if (-not $evidence.StartsWith($repository + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'Evidence output must remain inside the repository.'
+}
+$administrator = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
+ [Security.Principal.WindowsBuiltInRole]::Administrator)
+if (-not $administrator) { throw 'Published HTTP verification must run as Windows Administrator.' }
+
+$agent = Join-Path $bundle 'agent-mcp.exe'
+$config = Join-Path $bundle 'agent.yaml'
+$clientProject = Join-Path $repository 'scripts\E2EClient\E2EClient.csproj'
+$runRoot = Join-Path $testRootPath ('agent-mcp-http-cmd-' + [Guid]::NewGuid().ToString('N'))
+$runRootForward = $runRoot.Replace('\', '/')
+$endpoint = 'http://127.0.0.1:8080/mcp'
+$log = Join-Path $evidence 'verification.log'
+$server = $null
+New-Item -ItemType Directory -Force -Path $evidence | Out-Null
+New-Item -ItemType Directory -Path $runRoot | Out-Null
+$aclBefore = (Get-Acl -LiteralPath $runRoot).Sddl
+
+$request = @"
+実作業を行ってください。作業ディレクトリは $runRootForward です。
+file_editで probe.py と run-probe.cmd を作成してください。
+probe.pyはsys.prefixがC:\tmp\.venvであることを検証し、VENV_PYTHON_OKと表示してください。
+run-probe.cmdは次を順番に実行し、どれかが失敗したら非0で終了してください。
+1. 非修飾名 cmd.exe を子Processとして実行する。
+2. 非修飾名 curl.exe --version を子Processとして実行する。
+3. curl.exeで $endpoint へ接続を試み、sandbox.network.access=noneにより失敗することを確認する。接続できた場合は失敗とする。
+4. Workspace相対path ..\.venv\Scripts\python.exe でprobe.pyを実行する。
+最後にInternal Tool process_runを executable=cmd、cwd=$runRootForward、args=["/d","/c","call run-probe.cmd"] で実行し、exit code 0とCMD_HTTP_MCP_OKを確認してください。説明だけで完了せず、必ずファイル作成とprocess_runを実行してください。
+"@
+
+try {
+ & $agent check --config $config --lang ja 2>&1 | Tee-Object -FilePath $log
+ if ($LASTEXITCODE -ne 0) { throw "Published check failed with exit code $LASTEXITCODE." }
+
+ $server = Start-Process -FilePath $agent -ArgumentList @('serve','--config',$config,'--lang','ja','--transport','http') `
+ -WorkingDirectory $bundle -WindowStyle Hidden -PassThru
+ $ready = $false
+ for ($attempt = 0; $attempt -lt 100; $attempt++) {
+ if ($server.HasExited) { throw "Published server exited before readiness: $($server.ExitCode)" }
+ try {
+ $response = Invoke-WebRequest -Uri $endpoint -Method Get -SkipHttpErrorCheck
+ if ($response.StatusCode -eq 405) { $ready = $true; break }
+ }
+ catch { }
+ Start-Sleep -Milliseconds 100
+ }
+ if (-not $ready) { throw 'Published HTTP MCP server did not become ready.' }
+
+ dotnet run --project $clientProject -c Release --no-build -- http $endpoint unused $request 2>&1 |
+ Tee-Object -FilePath $log -Append
+ if ($LASTEXITCODE -ne 0) { throw "HTTP MCP agent_run failed with exit code $LASTEXITCODE." }
+ if (-not (Test-Path -LiteralPath (Join-Path $runRoot 'probe.py')) -or
+ -not (Test-Path -LiteralPath (Join-Path $runRoot 'run-probe.cmd'))) {
+ throw 'MCP Task did not create the required verification files.'
+ }
+}
+finally {
+ if ($server -and -not $server.HasExited) { $server.Kill($true); $server.WaitForExit() }
+ if (Test-Path -LiteralPath $runRoot) {
+ if ((Get-Acl -LiteralPath $runRoot).Sddl -ne $aclBefore) { throw 'MCP Task did not restore the test directory ACL.' }
+ $resolvedRunRoot = (Resolve-Path -LiteralPath $runRoot).Path
+ if (-not $resolvedRunRoot.StartsWith($testRootPath.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'Refusing to clean a test directory outside TestRoot.'
+ }
+ Remove-Item -LiteralPath $resolvedRunRoot -Recurse -Force
+ }
+}
+
+Write-Output "Published HTTP MCP cmd verification PASS. Evidence: $evidence"
diff --git a/scripts/Verify-Readme.ps1 b/scripts/Verify-Readme.ps1
new file mode 100644
index 0000000..f67f640
--- /dev/null
+++ b/scripts/Verify-Readme.ps1
@@ -0,0 +1,258 @@
+param(
+ [Parameter(Mandatory)]
+ [string]$TestRoot,
+ [Parameter(Mandatory)]
+ [string]$VenvPython,
+ [string]$EvidenceRoot = 'artifacts\evidence\readme-verification',
+ [switch]$CliOnly
+)
+
+$ErrorActionPreference = 'Stop'
+$OutputEncoding = [Text.UTF8Encoding]::new($false)
+[Console]::OutputEncoding = $OutputEncoding
+$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+$testRootPath = (Resolve-Path -LiteralPath $TestRoot).Path
+$venvPythonPath = (Resolve-Path -LiteralPath $VenvPython).Path
+$evidence = [IO.Path]::GetFullPath((Join-Path $repository $EvidenceRoot))
+if (-not $evidence.StartsWith($repository + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'Evidence output must remain inside the repository.'
+}
+$administrator = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
+ [Security.Principal.WindowsBuiltInRole]::Administrator)
+if (-not $administrator -and -not $CliOnly) { throw 'README verification must run as Windows Administrator unless -CliOnly is specified.' }
+if (-not (Test-Path -LiteralPath $venvPythonPath -PathType Leaf)) { throw 'VenvPython must be an existing file.' }
+
+$pwshPath = (Get-Command pwsh.exe -ErrorAction Stop).Source
+$pwshRuntime = Split-Path $pwshPath
+$cmdPath = $env:ComSpec
+$venvRoot = Split-Path (Split-Path $venvPythonPath)
+$pyvenv = Get-Content -LiteralPath (Join-Path $venvRoot 'pyvenv.cfg')
+$pythonRuntime = (($pyvenv | Where-Object { $_ -match '^home\s*=' }) -split '=', 2)[1].Trim()
+if (-not (Test-Path -LiteralPath $pythonRuntime -PathType Container)) { throw "Python Runtime Root does not exist: $pythonRuntime" }
+$basePythonPath = Join-Path $pythonRuntime 'python.exe'
+if (-not (Test-Path -LiteralPath $basePythonPath -PathType Leaf)) { throw "Base Python does not exist: $basePythonPath" }
+
+New-Item -ItemType Directory -Force -Path $evidence | Out-Null
+$runRoot = Join-Path $testRootPath ('agent-mcp-readme-cli-' + [Guid]::NewGuid().ToString('N'))
+New-Item -ItemType Directory -Path $runRoot | Out-Null
+$agent = Join-Path $repository 'build\Release\Agent.App\net10.0-windows10.0.26100.0\agent-mcp.exe'
+$windowsTests = Join-Path $repository 'build\Release\Agent.Windows.Tests\net10.0-windows10.0.26100.0\Agent.Windows.Tests.exe'
+$configPath = Join-Path $runRoot 'agent.yaml'
+$logPath = Join-Path $evidence 'verification.log'
+$failed = $false
+$aliases = @(
+ ('readme-python-' + [Guid]::NewGuid().ToString('N').Substring(0, 8))
+ ('readme-powershell-' + [Guid]::NewGuid().ToString('N').Substring(0, 8))
+ ('readme-cmd-' + [Guid]::NewGuid().ToString('N').Substring(0, 8))
+)
+$pythonAlias, $powershellAlias, $cmdAlias = $aliases
+$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
+$listener.Start()
+$httpPort = ([Net.IPEndPoint]$listener.LocalEndpoint).Port
+$listener.Stop()
+$resolutionStorePath = $null
+$server = $null
+
+function Invoke-Step {
+ param([string]$Name, [scriptblock]$Action)
+ "START $Name $(Get-Date -Format o)" | Tee-Object -FilePath $logPath -Append
+ try {
+ $global:LASTEXITCODE = 0
+ & $Action *>&1 | Tee-Object -FilePath $logPath -Append
+ if ($LASTEXITCODE -ne 0) { throw "$Name exited with $LASTEXITCODE" }
+ "PASS $Name $(Get-Date -Format o)" | Tee-Object -FilePath $logPath -Append
+ }
+ catch {
+ $_ | Out-String | Tee-Object -FilePath $logPath -Append
+ $script:failed = $true
+ "FAIL $Name $(Get-Date -Format o)" | Tee-Object -FilePath $logPath -Append
+ }
+}
+
+function Invoke-ExpectedFailure {
+ param([string]$Name, [string[]]$Arguments)
+ "START $Name $(Get-Date -Format o)" | Tee-Object -FilePath $logPath -Append
+ & $agent @Arguments *>&1 | Tee-Object -FilePath $logPath -Append
+ if ($LASTEXITCODE -ne 2) {
+ $script:failed = $true
+ "FAIL $Name expected_exit=2 actual_exit=$LASTEXITCODE" | Tee-Object -FilePath $logPath -Append
+ }
+ else { "PASS $Name expected_exit=2" | Tee-Object -FilePath $logPath -Append }
+}
+
+function Start-And-ProbeServer {
+ param([bool]$ExplicitServe)
+ $arguments = if ($ExplicitServe) {
+ @('serve', '--config', $configPath, '--lang', 'ja', '--transport', 'http')
+ } else {
+ @('--config', $configPath, '--lang', 'ja', '--transport', 'http')
+ }
+ $script:server = Start-Process -FilePath $agent -ArgumentList $arguments -WorkingDirectory (Split-Path $agent) -WindowStyle Hidden -PassThru
+ $endpoint = "http://127.0.0.1:$httpPort/mcp"
+ for ($attempt = 0; $attempt -lt 100; $attempt++) {
+ if ($script:server.HasExited) { throw "Server exited before becoming ready: $($script:server.ExitCode)" }
+ try {
+ $response = Invoke-WebRequest -Uri $endpoint -Method Get -SkipHttpErrorCheck
+ if ($response.StatusCode -eq 405) { return }
+ } catch { }
+ Start-Sleep -Milliseconds 100
+ }
+ throw 'Server did not become ready.'
+}
+
+try {
+ $forwardRoot = $testRootPath.Replace('\', '/')
+ $forwardPrompt = (Join-Path $repository 'prompts\user-system.md').Replace('\', '/')
+ $forwardSkills = (Join-Path $repository 'skills').Replace('\', '/')
+ $forwardLocales = (Join-Path $repository 'config\locales').Replace('\', '/')
+ $forwardLogs = (Join-Path $runRoot 'logs').Replace('\', '/')
+ @"
+schema_version: 1
+application:
+ language: en
+mcp:
+ transport: http
+ http:
+ host: 127.0.0.1
+ port: $httpPort
+ images:
+ max_count: 4
+ max_bytes_each: 10485760
+ max_total_bytes: 20971520
+ tasks:
+ time_to_live_hours: 24
+ poll_interval_ms: 1000
+agent:
+ user_system_prompt: $forwardPrompt
+ max_iterations: 8
+ same_failure_limit: 2
+ollama:
+ endpoint: http://127.0.0.1:11434
+ model: deepseek-v4-flash:cloud
+ think: true
+ keep_alive: 5m
+ options:
+ num_ctx: 32768
+ temperature: 0
+ extra_options: {}
+planner:
+ max_tasks: 8
+sandbox:
+ profile: development
+ workspaces:
+ - $forwardRoot
+ mounts: []
+ execution:
+ executables:
+ - $pythonAlias
+ shells:
+ - $powershellAlias
+ - $cmdAlias
+ network:
+ access: none
+ local_endpoints: []
+ restricted_endpoints: []
+ resources:
+ profile: constrained
+file_edit:
+ memory_backup_limit_mb: 16
+limits:
+ whole_read_bytes: 1048576
+ search_max_results: 1000
+ tail_max_lines: 10000
+ context_summary_ratio: 0.8
+localization:
+ directory: $forwardLocales
+ fallback: en
+skills:
+ directory: $forwardSkills
+logging:
+ directory: $forwardLogs
+ level: information
+ http_requests: true
+ max_file_bytes: 10485760
+ retained_files: 5
+"@ | Set-Content -LiteralPath $configPath -Encoding utf8NoBOM
+
+ @(
+ "timestamp=$(Get-Date -Format o)",
+ "commit=$(git -C $repository rev-parse HEAD)",
+ "administrator=$administrator",
+ "os=$([Environment]::OSVersion.VersionString)",
+ "venv_python=$venvPythonPath",
+ "venv_version=$(& $venvPythonPath --version)",
+ "powershell=$pwshPath",
+ "cmd=$cmdPath"
+ ) | Set-Content -LiteralPath (Join-Path $evidence 'metadata.txt') -Encoding utf8NoBOM
+
+ Invoke-Step 'cli-sandbox-resolve' { & $agent sandbox resolve --config $configPath --lang ja }
+ Invoke-Step 'cli-sandbox-confirm-yes' { & $agent sandbox confirm --yes --config $configPath --lang ja }
+ $normalizedConfig = [IO.Path]::GetFullPath($configPath).ToUpperInvariant()
+ $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($normalizedConfig)))
+ $resolutionStorePath = Join-Path $env:LOCALAPPDATA "AgentSandboxMcp\environment-resolutions\$hash.json"
+ Invoke-Step 'cli-executable-resolve-path' { & $agent executable resolve $pythonAlias --path $basePythonPath --config $configPath --lang ja }
+ Invoke-Step 'cli-executable-approve-runtime-root-yes' { & $agent executable approve $pythonAlias --path $basePythonPath --runtime-root $pythonRuntime --yes --config $configPath --lang ja }
+ Invoke-Step 'cli-shell-resolve-powershell' { & $agent executable resolve $powershellAlias --path $pwshPath --config $configPath --lang en }
+ Invoke-Step 'cli-shell-approve-runtime-root-yes' { & $agent executable approve $powershellAlias --path $pwshPath --runtime-root $pwshRuntime --yes --config $configPath --lang en }
+ Invoke-Step 'cli-shell-resolve-cmd' { & $agent executable resolve $cmdAlias --path $cmdPath --config $configPath --lang en }
+ Invoke-Step 'cli-shell-approve-cmd-no-runtime' { & $agent executable approve $cmdAlias --path $cmdPath --no-runtime --yes --config $configPath --lang en }
+ Invoke-Step 'cli-executable-list' { & $agent executable list --config $configPath --lang ja }
+ Invoke-Step 'cli-sandbox-show-text-default-format' { & $agent sandbox show --config $configPath --lang en }
+ Invoke-Step 'cli-sandbox-show-json' { & $agent sandbox show --format json --config $configPath --lang ja }
+ if ($CliOnly) {
+ Invoke-ExpectedFailure 'cli-check-detects-non-admin' @('check', '--config', $configPath, '--lang', 'ja')
+ }
+ else {
+ Invoke-Step 'cli-check' { & $agent check --config $configPath --lang ja }
+ }
+
+ Invoke-ExpectedFailure 'cli-invalid-transport' @('serve', '--config', $configPath, '--transport', 'invalid')
+ Invoke-ExpectedFailure 'cli-invalid-format' @('sandbox', 'show', '--format', 'xml', '--config', $configPath)
+ Invoke-ExpectedFailure 'cli-management-missing-config' @('sandbox', 'resolve')
+ Invoke-ExpectedFailure 'cli-mutually-exclusive-runtime-options' @('executable', 'approve', $pythonAlias, '--path', $basePythonPath, '--runtime-root', $pythonRuntime, '--no-runtime', '--config', $configPath)
+
+ if (-not $CliOnly) {
+ Invoke-Step 'cli-serve-explicit-http' {
+ Start-And-ProbeServer $true
+ $script:server.Kill($true)
+ $script:server.WaitForExit()
+ $script:server = $null
+ }
+ Invoke-Step 'cli-serve-implicit-option-first' {
+ Start-And-ProbeServer $false
+ $script:server.Kill($true)
+ $script:server.WaitForExit()
+ $script:server = $null
+ }
+
+ $env:AGENT_MCP_MONKEY_ROOT = $testRootPath
+ $env:AGENT_MCP_TEST_VENV_PYTHON = $venvPythonPath
+ $env:AGENT_MCP_TEST_POWERSHELL = $pwshPath
+ $env:AGENT_MCP_TEST_POWERSHELL_RUNTIME = $pwshRuntime
+ $env:AGENT_MCP_TEST_CMD = $cmdPath
+ Invoke-Step 'readme-operational-windows-tests' {
+ & $windowsTests -class Agent.Windows.Tests.ReadmeOperationalTests -parallel none -failSkips
+ }
+ }
+ Invoke-Step 'cli-executable-revoke-python' { & $agent executable revoke $pythonAlias --yes --config $configPath --lang ja }
+ Invoke-Step 'cli-executable-revoke-powershell' { & $agent executable revoke $powershellAlias --yes --config $configPath --lang en }
+ Invoke-Step 'cli-executable-revoke-cmd' { & $agent executable revoke $cmdAlias --yes --config $configPath --lang en }
+}
+finally {
+ if ($server -and -not $server.HasExited) { $server.Kill($true); $server.WaitForExit() }
+ foreach ($alias in $aliases) {
+ & $agent executable revoke $alias --yes --config $configPath --lang en 2>$null | Out-Null
+ }
+ if ($resolutionStorePath -and (Test-Path -LiteralPath $resolutionStorePath)) { Remove-Item -LiteralPath $resolutionStorePath -Force }
+ if (Test-Path -LiteralPath $runRoot) {
+ $resolvedRunRoot = (Resolve-Path -LiteralPath $runRoot).Path
+ if (-not $resolvedRunRoot.StartsWith($testRootPath.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'Refusing to clean a test directory outside TestRoot.'
+ }
+ Remove-Item -LiteralPath $resolvedRunRoot -Recurse -Force
+ }
+ Remove-Item Env:AGENT_MCP_MONKEY_ROOT,Env:AGENT_MCP_TEST_VENV_PYTHON,Env:AGENT_MCP_TEST_POWERSHELL,Env:AGENT_MCP_TEST_POWERSHELL_RUNTIME,Env:AGENT_MCP_TEST_CMD -ErrorAction SilentlyContinue
+}
+
+if ($failed) { throw "README verification failed. Evidence: $evidence" }
+Write-Output "README verification PASS. Evidence: $evidence"
diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs
index af4c906..ec122e9 100644
--- a/src/Agent.Windows/Tools/WindowsToolBackend.cs
+++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs
@@ -48,6 +48,10 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit
_taskUpdates.BeginTask(taskId, sessionId);
try
{
+ string[] osRuntimeRoots = settings.Files
+ .Where(static entry => entry.Origin.Split(',').Contains("os_runtime_root", StringComparer.Ordinal))
+ .Select(static entry => ResolvedPath.Resolve(entry.Path, false))
+ .ToArray();
foreach ((string path, FileCapability capabilities, bool osRuntimeRoot) in settings.Files
.GroupBy(static entry => ResolvedPath.Resolve(entry.Path, false), StringComparer.OrdinalIgnoreCase)
.Select(static group => (group.Key,
@@ -57,9 +61,12 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit
// System32 has a protected OS-owned DACL. Its existing LPAC access is verified by
// real process-tree tests; changing or unprotecting that DACL is forbidden.
if (osRuntimeRoot) continue;
- // OS/runtime executables may already grant AppContainer execute through their protected system ACL.
- // Do not rewrite a file ACL when execute is the only requested capability; CreateProcess remains
- // constrained by LPAC access plus Resolved Path/SHA-256 Entry approval.
+ // Approved System32 Entry files are nested under the same protected OS Runtime Root.
+ // Their existing LPAC access is used; a separate file-level DACL rewrite is forbidden.
+ if (File.Exists(path) && capabilities == (FileCapability.Read | FileCapability.Execute) &&
+ osRuntimeRoots.Any(root => IsBelow(path, root))) continue;
+ // Executable-only entries may already grant AppContainer execute through their system ACL.
+ // CreateProcess remains constrained by LPAC access plus Resolved Path/SHA-256 Entry approval.
if (File.Exists(path) && capabilities == FileCapability.Execute) continue;
// A single inheritable ACE on an approved directory covers existing and newly-created
// descendants. Snapshotting and rewriting every child ACL is both redundant and
@@ -305,6 +312,9 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit
}
private TaskContext Get(Guid taskId) => _contexts.TryGetValue(taskId, out TaskContext? context) ? context : throw new InvalidOperationException("Task Resource scope is not active.");
+ private static bool IsBelow(string child, string parent) =>
+ child.StartsWith(parent.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar,
+ StringComparison.OrdinalIgnoreCase);
private static ToolExecutionResult Success(string output, bool externalEffect = false) => new(true, output, externalEffect);
private static bool IsTextMatch(string path, Func<string, bool> predicate)
{
diff --git a/tests/Agent.Windows.Tests/ReadmeOperationalTests.cs b/tests/Agent.Windows.Tests/ReadmeOperationalTests.cs
new file mode 100644
index 0000000..fd70f06
--- /dev/null
+++ b/tests/Agent.Windows.Tests/ReadmeOperationalTests.cs
@@ -0,0 +1,446 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Security.AccessControl;
+using System.Text;
+using Agent.Windows.Network;
+using Agent.Windows.Recovery;
+using Agent.Windows.Security;
+
+namespace Agent.Windows.Tests;
+
+public sealed class ReadmeOperationalTests
+{
+ [Theory(Timeout = 120_000)]
+ [InlineData("powershell")]
+ [InlineData("cmd")]
+ [InlineData("venv-python")]
+ public void Readme_script_runs_in_the_configured_runtime(string executable)
+ {
+ RuntimeFixture runtime = RuntimeFixture.FromEnvironment();
+ using var fixture = new TemporaryDirectory(runtime.TestRoot);
+ string marker = Path.Combine(fixture.Path, executable + ".txt");
+ string script;
+ IReadOnlyList<string> arguments;
+ string expected;
+ if (executable == "powershell")
+ {
+ script = Path.Combine(fixture.Path, "probe.ps1");
+ File.WriteAllText(script, $"Set-Content -LiteralPath '{marker}' -Value 'POWERSHELL_OK'", new UTF8Encoding(false));
+ arguments = ["-NoLogo", "-NoProfile", "-NonInteractive", "-File", script];
+ expected = "POWERSHELL_OK";
+ }
+ else if (executable == "cmd")
+ {
+ script = Path.Combine(fixture.Path, "probe.cmd");
+ File.WriteAllText(script, $"@echo off\r\n>\"{marker}\" echo CMD_OK\r\n", Encoding.ASCII);
+ arguments = ["/d", "/c", "call", script];
+ expected = "CMD_OK";
+ }
+ else
+ {
+ script = Path.Combine(fixture.Path, "probe.py");
+ File.WriteAllText(script,
+ $"import pathlib,sys\npathlib.Path({PythonLiteral(marker)}).write_text('PYTHON_OK|'+sys.prefix, encoding='utf-8')\n",
+ new UTF8Encoding(false));
+ arguments = [script];
+ expected = "PYTHON_OK|";
+ }
+ using SandboxScope scope = runtime.CreateScope(fixture.Path, allowNetwork: false);
+ RestrictedProcessResult result = scope.Run(executable, arguments, fixture.Path);
+
+ Assert.True(result.ExitCode == 0u, $"{executable} exited {result.ExitCode}. stdout={result.StandardOutput}; stderr={result.StandardError}");
+ string output = File.ReadAllText(marker).Trim();
+ Assert.StartsWith(expected, output, StringComparison.Ordinal);
+ if (executable == "venv-python")
+ Assert.Contains(Path.GetFullPath(runtime.VenvRoot), output, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Theory(Timeout = 120_000)]
+ [InlineData("powershell")]
+ [InlineData("cmd")]
+ [InlineData("venv-python")]
+ public void Network_none_blocks_powershell_cmd_and_python(string executable)
+ {
+ RuntimeFixture runtime = RuntimeFixture.FromEnvironment();
+ using var fixture = new TemporaryDirectory(runtime.TestRoot);
+ using var deniedServer = new OneShotHttpServer();
+ using SandboxScope deniedScope = runtime.CreateScope(fixture.Path, allowNetwork: false);
+ RestrictedProcessResult denied = RunNetworkProbe(deniedScope, executable, deniedServer.Url, fixture.Path);
+ Assert.DoesNotContain("not recognized as an internal or external command", denied.StandardError, StringComparison.OrdinalIgnoreCase);
+ Assert.NotEqual(0u, denied.ExitCode);
+ Assert.False(deniedServer.Accepted);
+ }
+
+ [Theory(Timeout = 180_000)]
+ [InlineData("powershell")]
+ [InlineData("cmd")]
+ [InlineData("venv-python")]
+ public void Network_local_allows_only_the_configured_port(string executable)
+ {
+ RuntimeFixture runtime = RuntimeFixture.FromEnvironment();
+ using var fixture = new TemporaryDirectory(runtime.TestRoot);
+ using var allowedServer = new OneShotHttpServer();
+ using var wrongPortServer = new OneShotHttpServer();
+ using (SandboxScope localScope = runtime.CreateScope(fixture.Path, allowNetwork: true))
+ {
+ NetworkEndpoint[] endpoints = [new(IPAddress.Loopback, checked((ushort)allowedServer.Port))];
+ using NetworkLease localPolicy = NetworkLease.Apply(localScope, endpoints);
+ RestrictedProcessResult allowed = RunNetworkProbe(localScope, executable, allowedServer.Url, fixture.Path);
+ RestrictedProcessResult wrongPort = RunNetworkProbe(localScope, executable, wrongPortServer.Url, fixture.Path);
+ Assert.True(allowed.ExitCode == 0u,
+ $"{executable} could not access its allowed loopback port. stdout={allowed.StandardOutput}; stderr={allowed.StandardError}");
+ Assert.True(allowedServer.Accepted);
+ Assert.NotEqual(0u, wrongPort.ExitCode);
+ Assert.False(wrongPortServer.Accepted);
+ }
+ }
+
+ [Theory(Timeout = 120_000)]
+ [InlineData("powershell", "cmd")]
+ [InlineData("powershell", "curl")]
+ [InlineData("powershell", "venv-python")]
+ [InlineData("cmd", "cmd")]
+ [InlineData("cmd", "curl")]
+ [InlineData("cmd", "venv-python")]
+ [InlineData("venv-python", "cmd")]
+ [InlineData("venv-python", "curl")]
+ [InlineData("venv-python", "venv-python")]
+ public void Lpac_descendant_compatibility_matrix(string parent, string child)
+ {
+ RuntimeFixture runtime = RuntimeFixture.FromEnvironment();
+ using var fixture = new TemporaryDirectory(runtime.TestRoot);
+ string pythonProbe = Path.Combine(fixture.Path, "descendant-probe.py");
+ File.WriteAllText(pythonProbe, "raise SystemExit(0)\n", new UTF8Encoding(false));
+ using SandboxScope scope = runtime.CreateScope(fixture.Path, allowNetwork: false);
+ string childPath = child switch
+ {
+ "cmd" => runtime.Cmd,
+ "curl" => Path.Combine(Environment.SystemDirectory, "curl.exe"),
+ "venv-python" => runtime.Python,
+ _ => throw new ArgumentOutOfRangeException(nameof(child))
+ };
+ string[] childArguments = child switch
+ {
+ "cmd" => ["/d", "/c", "exit 0"],
+ "curl" => ["--version"],
+ "venv-python" => [pythonProbe],
+ _ => throw new ArgumentOutOfRangeException(nameof(child))
+ };
+ RestrictedProcessResult result = parent switch
+ {
+ "powershell" => scope.Run(parent,
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
+ $"& '{childPath}' {string.Join(' ', childArguments.Select(PowerShellLiteral))}; exit $LASTEXITCODE"], fixture.Path),
+ "cmd" => scope.Run(parent, ["/d", "/c", child switch
+ {
+ "cmd" => "cmd.exe /d /c exit 0",
+ "curl" => "curl.exe --version",
+ "venv-python" => $"{Path.GetRelativePath(fixture.Path, runtime.Python)} {Path.GetFileName(pythonProbe)}",
+ _ => throw new ArgumentOutOfRangeException(nameof(child))
+ }], fixture.Path),
+ "venv-python" => scope.Run(parent,
+ ["-c", $"import subprocess; raise SystemExit(subprocess.run({System.Text.Json.JsonSerializer.Serialize(new[] { childPath }.Concat(childArguments).ToArray())}).returncode)"], fixture.Path),
+ _ => throw new ArgumentOutOfRangeException(nameof(parent))
+ };
+ Assert.True(result.ExitCode == 0u,
+ $"LPAC descendant pair {parent}->{child} failed. stdout={result.StandardOutput}; stderr={result.StandardError}");
+ }
+
+ [Fact(Timeout = 180_000)]
+ public void Acl_monkey_round_trips_randomized_directory_and_file_descriptors()
+ {
+ string testRoot = RequiredDirectory("AGENT_MCP_MONKEY_ROOT");
+ AppContainerProfile profile = AppContainerProfile.EnsureFixed();
+ var audit = new MemorySecurityAuditSink();
+ var acl = new TaskAclManager(profile.Sid, audit);
+ var random = new Random(0x5A17);
+
+ for (int iteration = 0; iteration < 64; iteration++)
+ {
+ using var fixture = new TemporaryDirectory(testRoot);
+ string nested = Path.Combine(fixture.Path, $"d-{iteration:D2}", "nested");
+ Directory.CreateDirectory(nested);
+ string file = Path.Combine(nested, $"f-{random.Next():X8}.txt");
+ File.WriteAllText(file, "acl-monkey", new UTF8Encoding(false));
+
+ if ((iteration & 1) != 0)
+ {
+ DirectorySecurity security = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(fixture.Path), AccessControlSections.Access);
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: true);
+ FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(fixture.Path), security);
+ }
+
+ Dictionary<string, byte[]> before = SnapshotTree(fixture.Path);
+ FileCapability directoryCapability = RandomDirectoryCapability(random);
+ FileCapability fileCapability = RandomFileCapability(random);
+ Guid taskId = Guid.NewGuid();
+ AclSnapshot directorySnapshot = acl.Grant(taskId, fixture.Path, directoryCapability);
+ AclSnapshot fileSnapshot = acl.Grant(taskId, file, fileCapability);
+ try
+ {
+ Assert.NotEmpty(AccessRules(fixture.Path, profile.Sid));
+ Assert.NotEmpty(AccessRules(file, profile.Sid));
+ }
+ finally
+ {
+ acl.Restore(taskId, fileSnapshot);
+ acl.Restore(taskId, directorySnapshot);
+ }
+
+ Dictionary<string, byte[]> after = SnapshotTree(fixture.Path);
+ Assert.Equal(before.Keys.Order(), after.Keys.Order());
+ foreach ((string path, byte[] descriptor) in before)
+ {
+ string beforeSddl = ToSddl(descriptor);
+ string afterSddl = ToSddl(after[path]);
+ Assert.DoesNotContain(profile.Sid.Value, afterSddl, StringComparison.OrdinalIgnoreCase);
+ if (path.Equals(fixture.Path, StringComparison.OrdinalIgnoreCase))
+ Assert.True(descriptor.AsSpan().SequenceEqual(after[path]), $"Modified root descriptor did not round-trip at iteration {iteration}: {path}");
+ else
+ Assert.Equal(AclSemantics(path, beforeSddl), AclSemantics(path, afterSddl));
+ }
+ }
+
+ Assert.Equal(128, audit.Events.Count(item => item.Event == "acl.apply"));
+ Assert.Equal(128, audit.Events.Count(item => item.Event == "acl.restore"));
+ }
+
+ private static RestrictedProcessResult RunNetworkProbe(SandboxScope scope, string executable, string url, string cwd) => executable switch
+ {
+ "powershell" => scope.Run(executable,
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", $"try {{ (Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri '{url}').Content | Out-Null; exit 0 }} catch {{ exit 71 }}"], cwd),
+ "cmd" => scope.Run(executable,
+ ["/d", "/c", $"curl.exe --noproxy * --silent --show-error --max-time 2 {url} >nul"], cwd),
+ "venv-python" => scope.Run(executable,
+ ["-c", $"import urllib.request; urllib.request.urlopen({PythonLiteral(url)}, timeout=2).read()"], cwd),
+ _ => throw new ArgumentOutOfRangeException(nameof(executable))
+ };
+
+ private static string PythonLiteral(string value) => System.Text.Json.JsonSerializer.Serialize(value);
+ private static string PowerShellLiteral(string value) => "'" + value.Replace("'", "''", StringComparison.Ordinal) + "'";
+
+ private static FileCapability RandomDirectoryCapability(Random random)
+ {
+ FileCapability[] values =
+ [
+ FileCapability.Read,
+ FileCapability.Write,
+ FileCapability.Read | FileCapability.Write,
+ FileCapability.Read | FileCapability.Execute,
+ FileCapability.Read | FileCapability.Write | FileCapability.Delete,
+ FileCapability.Read | FileCapability.Write | FileCapability.Execute | FileCapability.Delete
+ ];
+ return values[random.Next(values.Length)];
+ }
+
+ private static FileCapability RandomFileCapability(Random random)
+ {
+ FileCapability[] values =
+ [
+ FileCapability.Read,
+ FileCapability.Write,
+ FileCapability.Execute,
+ FileCapability.Read | FileCapability.Write,
+ FileCapability.Read | FileCapability.Execute,
+ FileCapability.Read | FileCapability.Write | FileCapability.Execute | FileCapability.Delete
+ ];
+ return values[random.Next(values.Length)];
+ }
+
+ private static Dictionary<string, byte[]> SnapshotTree(string root) =>
+ new[] { root }.Concat(Directory.EnumerateFileSystemEntries(root, "*", SearchOption.AllDirectories))
+ .ToDictionary(path => path, AccessDescriptor, StringComparer.OrdinalIgnoreCase);
+
+ private static byte[] AccessDescriptor(string path)
+ {
+ FileSystemSecurity security = Directory.Exists(path)
+ ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access)
+ : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access);
+ return security.GetSecurityDescriptorBinaryForm();
+ }
+
+ private static string ToSddl(byte[] descriptor)
+ {
+ var raw = new System.Security.AccessControl.RawSecurityDescriptor(descriptor, 0);
+ return raw.GetSddlForm(AccessControlSections.All);
+ }
+
+ private static string[] AclSemantics(string path, string sddl)
+ {
+ var raw = new System.Security.AccessControl.RawSecurityDescriptor(sddl);
+ string flags = $"protected={raw.ControlFlags.HasFlag(ControlFlags.DiscretionaryAclProtected)}";
+ IEnumerable<string> aces = (raw.DiscretionaryAcl?.Cast<System.Security.AccessControl.GenericAce>() ?? [])
+ .Select(ace =>
+ {
+ byte[] binary = new byte[ace.BinaryLength];
+ ace.GetBinaryForm(binary, 0);
+ return Convert.ToHexString(binary);
+ })
+ .Order(StringComparer.Ordinal);
+ return [path, flags, .. aces];
+ }
+
+ private static FileSystemAccessRule[] AccessRules(string path, System.Security.Principal.SecurityIdentifier sid)
+ {
+ FileSystemSecurity security = Directory.Exists(path)
+ ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access)
+ : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access);
+ return security.GetAccessRules(true, true, typeof(System.Security.Principal.SecurityIdentifier))
+ .OfType<FileSystemAccessRule>()
+ .Where(rule => rule.IdentityReference == sid && rule.AccessControlType == AccessControlType.Allow)
+ .ToArray();
+ }
+
+ private static string RequiredDirectory(string name)
+ {
+ string? value = Environment.GetEnvironmentVariable(name);
+ if (string.IsNullOrWhiteSpace(value) || !Directory.Exists(value))
+ throw new InvalidOperationException($"{name} must name an existing directory.");
+ return Path.GetFullPath(value);
+ }
+
+ private sealed record RuntimeFixture(string TestRoot, string VenvRoot, string Python, string PythonRuntimeRoot, string PowerShell, string? PowerShellRuntimeRoot, string Cmd)
+ {
+ public static RuntimeFixture FromEnvironment()
+ {
+ string testRoot = RequiredDirectory("AGENT_MCP_MONKEY_ROOT");
+ string python = RequiredFile("AGENT_MCP_TEST_VENV_PYTHON");
+ string venvRoot = Directory.GetParent(Path.GetDirectoryName(python)!)!.FullName;
+ string configuration = File.ReadAllText(Path.Combine(venvRoot, "pyvenv.cfg"));
+ string homeLine = configuration.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
+ .Single(line => line.StartsWith("home = ", StringComparison.OrdinalIgnoreCase));
+ string pythonRuntimeRoot = Path.GetFullPath(homeLine[7..].Trim());
+ string powershell = RequiredFile("AGENT_MCP_TEST_POWERSHELL");
+ string? powershellRuntimeRoot = Environment.GetEnvironmentVariable("AGENT_MCP_TEST_POWERSHELL_RUNTIME");
+ if (!string.IsNullOrWhiteSpace(powershellRuntimeRoot)) powershellRuntimeRoot = RequiredDirectory("AGENT_MCP_TEST_POWERSHELL_RUNTIME");
+ else powershellRuntimeRoot = null;
+ string cmd = RequiredFile("AGENT_MCP_TEST_CMD");
+ return new(testRoot, venvRoot, python, pythonRuntimeRoot, powershell, powershellRuntimeRoot, cmd);
+ }
+
+ public SandboxScope CreateScope(string workspace, bool allowNetwork) => new(this, workspace, allowNetwork);
+
+ private static string RequiredFile(string name)
+ {
+ string? value = Environment.GetEnvironmentVariable(name);
+ if (string.IsNullOrWhiteSpace(value) || !File.Exists(value))
+ throw new InvalidOperationException($"{name} must name an existing file.");
+ return ResolvedPath.Resolve(value);
+ }
+ }
+
+ private sealed class SandboxScope : IDisposable
+ {
+ private readonly TaskAclManager _acl;
+ private readonly List<AclSnapshot> _snapshots = [];
+ public SandboxScope(RuntimeFixture runtime, string workspace, bool allowNetwork)
+ {
+ Profile = AppContainerProfile.EnsureFixed();
+ Audit = new MemorySecurityAuditSink();
+ TaskId = Guid.NewGuid();
+ _acl = new TaskAclManager(Profile.Sid, Audit);
+ _snapshots.Add(_acl.Grant(TaskId, workspace, FileCapability.Read | FileCapability.Write | FileCapability.Execute | FileCapability.Delete));
+ _snapshots.Add(_acl.Grant(TaskId, runtime.VenvRoot, FileCapability.Read | FileCapability.Execute));
+ _snapshots.Add(_acl.Grant(TaskId, runtime.PythonRuntimeRoot, FileCapability.Read | FileCapability.Execute));
+ if (runtime.PowerShellRuntimeRoot is not null)
+ _snapshots.Add(_acl.Grant(TaskId, runtime.PowerShellRuntimeRoot, FileCapability.Read | FileCapability.Execute));
+ ApprovedEntryExecutable[] approvals =
+ [
+ new("powershell", runtime.PowerShell, EntryExecutablePolicy.ComputeSha256(runtime.PowerShell), true, runtime.PowerShellRuntimeRoot),
+ new("cmd", runtime.Cmd, EntryExecutablePolicy.ComputeSha256(runtime.Cmd), true, null),
+ new("venv-python", runtime.Python, EntryExecutablePolicy.ComputeSha256(runtime.Python), false, runtime.PythonRuntimeRoot)
+ ];
+ string[] runtimeRoots = new[] { runtime.VenvRoot, runtime.PythonRuntimeRoot, runtime.PowerShellRuntimeRoot }
+ .OfType<string>().ToArray();
+ var policy = new EntryExecutablePolicy(approvals,
+ runtimeRoots, [workspace]);
+ Launcher = new RestrictedProcessLauncher(policy, Audit, Profile.Sid,
+ new SandboxJobLimits(8, 1024UL * 1024 * 1024), allowNetwork);
+ }
+
+ public AppContainerProfile Profile { get; }
+ public MemorySecurityAuditSink Audit { get; }
+ public Guid TaskId { get; }
+ public RestrictedProcessLauncher Launcher { get; }
+ public RestrictedProcessResult Run(string executable, IReadOnlyList<string> args, string cwd) =>
+ Launcher.Run(TaskId, executable, args, cwd, TimeSpan.FromSeconds(15));
+
+ public void Dispose()
+ {
+ foreach (AclSnapshot snapshot in _snapshots.AsEnumerable().Reverse()) _acl.Restore(TaskId, snapshot);
+ }
+ }
+
+ private sealed class NetworkLease : IDisposable
+ {
+ private readonly TaskResourceJournal _journal;
+ private readonly TaskNetworkPolicy _policy;
+ private NetworkLease(TaskResourceJournal journal, TaskNetworkPolicy policy) { _journal = journal; _policy = policy; }
+ public static NetworkLease Apply(SandboxScope scope, IReadOnlyList<NetworkEndpoint> endpoints)
+ {
+ string journalRoot = Path.Combine(Path.GetTempPath(), "agent-mcp-readme-network", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(journalRoot);
+ TaskResourceJournal journal = TaskResourceJournal.Create(journalRoot, scope.TaskId);
+ return new(journal, TaskNetworkPolicy.Apply(journal, scope.Profile.Sid, endpoints, scope.Audit));
+ }
+ public void Dispose()
+ {
+ _policy.Dispose();
+ _journal.CompleteAndDelete();
+ string? root = Path.GetDirectoryName(_journal.Path);
+ if (root is not null && Directory.Exists(root)) Directory.Delete(root, true);
+ }
+ }
+
+ private sealed class TemporaryDirectory : IDisposable
+ {
+ public TemporaryDirectory(string root)
+ {
+ Path = System.IO.Path.Combine(root, "agent-mcp-readme-test-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path);
+ }
+ public string Path { get; }
+ public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, true); }
+ }
+
+ private sealed class OneShotHttpServer : IDisposable
+ {
+ private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
+ private readonly CancellationTokenSource _stop = new();
+ private readonly Task _serve;
+ private int _accepted;
+ public OneShotHttpServer()
+ {
+ _listener.Start();
+ Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
+ Url = $"http://127.0.0.1:{Port}/";
+ _serve = ServeAsync();
+ }
+ public int Port { get; }
+ public string Url { get; }
+ public bool Accepted => Volatile.Read(ref _accepted) != 0;
+ private async Task ServeAsync()
+ {
+ try
+ {
+ using TcpClient client = await _listener.AcceptTcpClientAsync(_stop.Token);
+ Interlocked.Exchange(ref _accepted, 1);
+ using NetworkStream stream = client.GetStream();
+ byte[] buffer = new byte[4096];
+ _ = await stream.ReadAsync(buffer, _stop.Token);
+ byte[] response = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK");
+ await stream.WriteAsync(response, _stop.Token);
+ }
+ catch (OperationCanceledException) { }
+ catch (ObjectDisposedException) { }
+ catch (SocketException) when (_stop.IsCancellationRequested) { }
+ }
+ public void Dispose()
+ {
+ _stop.Cancel();
+ _listener.Stop();
+ try { _serve.Wait(TimeSpan.FromSeconds(2)); } catch (AggregateException) { }
+ _stop.Dispose();
+ }
+ }
+}
diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
index 09ed61d..d0c32a8 100644
--- a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
+++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
@@ -431,8 +431,10 @@ public sealed class SecurityPolicyTests
}
}
- [Fact]
- public void Lpac_baseline_allows_registry_read_but_denies_registry_write_and_com_activation()
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Lpac_capability_matrix_preserves_registry_and_com_denials(bool allowNetwork)
{
AppContainerProfile profile = AppContainerProfile.EnsureFixed();
using TestDirectory directory = new();
@@ -448,7 +450,7 @@ public sealed class SecurityPolicyTests
{
var launcher = new RestrictedProcessLauncher(
new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []),
- audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024));
+ audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024), allowNetwork);
string script = $"$ErrorActionPreference='Stop'; $p=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion').ProductName; " +
$"$writeDenied=$false; try {{ New-Item -Path 'HKCU:\\Software\\{registryName}' -Force | Out-Null }} catch {{ $writeDenied=$true }}; " +
"$comDenied=$false; try { $null=New-Object -ComObject Scripting.FileSystemObject } catch { $comDenied=$true }; " +
@@ -464,6 +466,9 @@ public sealed class SecurityPolicyTests
Assert.Contains("REGISTRY_WRITE_DENIED=True", result.StandardOutput, StringComparison.Ordinal);
Assert.Contains("COM_DENIED=True", result.StandardOutput, StringComparison.Ordinal);
Assert.Contains("BROKER_SECRET_INHERITED=False", result.StandardOutput, StringComparison.Ordinal);
+ Assert.Contains(audit.Events, item => item.Event == "process.start" &&
+ item.Data["baseline_capabilities"] == "registryRead,lpacInstrumentation" &&
+ item.Data["network_capabilities"] == (allowNetwork ? "internetClient,privateNetworkClientServer" : "none"));
}
finally
{
@@ -507,6 +512,31 @@ public sealed class SecurityPolicyTests
}
[Fact]
+ public void Approved_cmd_starts_unapproved_descendant_by_unqualified_name()
+ {
+ AppContainerProfile profile = AppContainerProfile.EnsureFixed();
+ using TestDirectory directory = new();
+ string cmd = Environment.GetEnvironmentVariable("ComSpec")!;
+ var audit = new MemorySecurityAuditSink();
+ TaskAclManager acl = new(profile.Sid, audit);
+ Guid taskId = Guid.NewGuid();
+ AclSnapshot snapshot = acl.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write);
+ try
+ {
+ var launcher = new RestrictedProcessLauncher(
+ new EntryExecutablePolicy([new ApprovedEntryExecutable("cmd", cmd, EntryExecutablePolicy.ComputeSha256(cmd), true, null)], [], []),
+ audit, profile.Sid, new SandboxJobLimits(4, 512UL * 1024 * 1024));
+ RestrictedProcessResult result = launcher.Run(taskId, "cmd", ["/d", "/c", "cmd.exe /d /c exit 0"],
+ directory.Path, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken);
+ Assert.Equal(0u, result.ExitCode);
+ Assert.Contains(audit.Events, item => item.Event == "process.start" &&
+ item.Data["token_is_app_container"] == bool.TrueString && item.Data["process_in_job"] == bool.TrueString &&
+ item.Data["descendant_policy"] == "lpac_inherited");
+ }
+ finally { acl.Restore(taskId, snapshot); }
+ }
+
+ [Fact]
public void Approved_python314_starts_python_subprocess_inside_lpac_job()
{
AppContainerProfile profile = AppContainerProfile.EnsureFixed();
@@ -656,8 +686,8 @@ public sealed class SecurityPolicyTests
snapshots.Add(acl.Grant(taskId, directory.Path,
FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute));
snapshots.Add(acl.Grant(taskId, runtimeRoot, FileCapability.Read | FileCapability.Execute));
- // System32 owns a protected DACL. Do not rewrite it; successful cmd descendants below
- // are the enforcement check for its existing LPAC read/execute access.
+ // System32 owns an OS-managed protected DACL. Do not rewrite it; descendant
+ // execution relies on its existing LPAC read/execute access.
var launcher = new RestrictedProcessLauncher(
new EntryExecutablePolicy([new ApprovedEntryExecutable("dotnet", dotnet, EntryExecutablePolicy.ComputeSha256(dotnet), false, runtimeRoot)],
[runtimeRoot], [directory.Path]), audit, profile.Sid, new SandboxJobLimits(16, 1024UL * 1024 * 1024));
@@ -975,6 +1005,43 @@ public sealed class SecurityPolicyTests
}
[Fact]
+ public async Task Os_runtime_root_protected_dacl_is_never_modified()
+ {
+ using TestDirectory directory = new();
+ string system32 = ResolvedPath.Resolve(Environment.SystemDirectory);
+ string cmd = ResolvedPath.Resolve(Environment.GetEnvironmentVariable("ComSpec")!);
+ DirectorySecurity security = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(system32), AccessControlSections.Access);
+ var raw = new RawSecurityDescriptor(security.GetSecurityDescriptorBinaryForm(), 0);
+ Assert.True(raw.ControlFlags.HasFlag(ControlFlags.DiscretionaryAclProtected));
+ string before = Convert.ToHexString(security.GetSecurityDescriptorBinaryForm());
+ string cmdBefore = Convert.ToHexString(FileSystemAclExtensions.GetAccessControl(
+ new FileInfo(cmd), AccessControlSections.Access).GetSecurityDescriptorBinaryForm());
+ var audit = new MemorySecurityAuditSink();
+ var settings = new EffectiveSandboxPolicy(
+ [new FilePolicyEntry(cmd, FileCapability.Read | FileCapability.Execute, "approved_shell_executable"),
+ new FilePolicyEntry(system32, FileCapability.Read | FileCapability.Execute, "os_runtime_root")],
+ [new ApprovedEntryExecutable("cmd", cmd, EntryExecutablePolicy.ComputeSha256(cmd), true, null)], [], [],
+ new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ 16 * 1024 * 1024, 1024 * 1024, 1000, 10000, BaselineCapabilities: ["registryRead", "lpacInstrumentation"]);
+ var backend = new WindowsToolBackend(settings, audit);
+ Guid taskId = Guid.NewGuid();
+
+ await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
+ await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
+
+ string after = Convert.ToHexString(FileSystemAclExtensions.GetAccessControl(
+ new DirectoryInfo(system32), AccessControlSections.Access).GetSecurityDescriptorBinaryForm());
+ string cmdAfter = Convert.ToHexString(FileSystemAclExtensions.GetAccessControl(
+ new FileInfo(cmd), AccessControlSections.Access).GetSecurityDescriptorBinaryForm());
+ Assert.Equal(before, after);
+ Assert.Equal(cmdBefore, cmdAfter);
+ Assert.DoesNotContain(audit.Events, item => item.Event is ("acl.apply" or "acl.restore") &&
+ item.Data.TryGetValue("path", out string? path) &&
+ (string.Equals(path, system32, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(path, cmd, StringComparison.OrdinalIgnoreCase)));
+ }
+
+ [Fact]
public async Task Dir_mkdir_creates_directory_within_approved_root_and_cleans_up()
{
using TestDirectory directory = new();