summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-13 18:20:33 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-13 18:20:33 +0900
commit76fdcb43585a4c4d288c1026d318a0ddbe3e26ab (patch)
treef58296832d37b035d3e787e0f270c77b59c89d77
parent78fe349073e3a2e89f0bf14b134ae0955aa7182a (diff)
fix(sandbox): correct cmd descendant handling and protected ACL policy
Replace the earlier cmd-wide descendant incompatibility diagnosis with the verified distinction between drive-qualified paths and PATH/workspace-relative invocation. Document the cmd YAML configuration, executable resolution, --no-runtime approval flow, and safe descendant invocation examples in the user README. Keep the System32 protected DACL intact for both the OS Runtime Root and approved entry files nested below it. Skip redundant file-level ACL rewrites for approved System32 executables while retaining LPAC confinement, resolved-path checks, SHA-256 approval, WFP policy, and Job Object enforcement. Expand Windows coverage with capability positive/negative matrices, protected-DACL regression tests, cmd/PowerShell/venv Python script execution, nine Entry-to-Descendant combinations, network none/local probes, and randomized ACL round-trip testing. Add read-only CLI/README verification and published HTTP MCP agent_run verification scripts. Update Design, Audit Report, and Requirement Traceability with the corrected root cause, current evidence, withdrawn diagnostic conclusions, and the remaining partial item for per-combination descendant self-recorded LPAC SID/Job evidence. Verified: Release build 0 warnings/errors; Core 103 PASS; Planner 10 PASS; Windows 75 PASS with skip 0; README operational suite 19 PASS; published HTTP MCP agent_run completed 3/3 with cmd, curl, C:\tmp\.venv Python, network-none enforcement, ACL restoration, and CMD_HTTP_MCP_OK.
-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();