summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AgentSandboxMcp.slnx2
-rw-r--r--README.md19
-rw-r--r--config/locales/en.json10
-rw-r--r--config/locales/ja.json10
-rw-r--r--doc/AuditReport.md4
-rw-r--r--doc/Design.md34
-rw-r--r--doc/Specifications.md21
-rw-r--r--plans/ImplementationPlan.md2
-rw-r--r--plans/RequirementTraceability.md15
-rw-r--r--scripts/Publish-Release.ps18
-rw-r--r--scripts/Run-Final-ElevatedVerification.ps131
-rw-r--r--scripts/Verify-PublishedHttp.ps13
-rw-r--r--scripts/Verify-Readme.ps12
-rw-r--r--src/Agent.Admin/Agent.Admin.Client/AdminProtocol.cs61
-rw-r--r--src/Agent.Admin/Agent.Admin.Client/Agent.Admin.Client.csproj7
-rw-r--r--src/Agent.Admin/Agent.Admin.Client/ElevatedNetworkSession.cs237
-rw-r--r--src/Agent.Admin/Agent.Admin.Host/Agent.Admin.Host.csproj19
-rw-r--r--src/Agent.Admin/Agent.Admin.Host/LoopbackExemption.cs (renamed from src/Agent.Windows/Network/LoopbackExemption.cs)2
-rw-r--r--src/Agent.Admin/Agent.Admin.Host/NativeMethods.txt27
-rw-r--r--src/Agent.Admin/Agent.Admin.Host/Program.cs211
-rw-r--r--src/Agent.Admin/Agent.Admin.Host/WfpDynamicSession.cs (renamed from src/Agent.Windows/Network/WfpDynamicSession.cs)21
-rw-r--r--src/Agent.Admin/Agent.Admin.Host/app.manifest10
-rw-r--r--src/Agent.App/Agent.App.csproj2
-rw-r--r--src/Agent.App/Program.cs27
-rw-r--r--src/Agent.App/app.manifest10
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs22
-rw-r--r--src/Agent.Windows/Agent.Windows.csproj1
-rw-r--r--src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs64
-rw-r--r--src/Agent.Windows/NativeMethods.txt26
-rw-r--r--src/Agent.Windows/Network/NetworkAllowanceSnapshot.cs27
-rw-r--r--src/Agent.Windows/Network/TaskNetworkPolicy.cs83
-rw-r--r--src/Agent.Windows/Recovery/RecoveryManager.cs52
-rw-r--r--src/Agent.Windows/Security/AppContainerProfile.cs8
-rw-r--r--src/Agent.Windows/Security/TaskAclManager.cs27
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs160
-rw-r--r--tests/Agent.Core.Tests/PromptAndRuntimeTests.cs35
-rw-r--r--tests/Agent.Windows.Tests/AdministrativeBoundaryTests.cs87
-rw-r--r--tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj1
-rw-r--r--tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs4
-rw-r--r--tests/Agent.Windows.Tests/DirectElevatedNetworkSession.cs75
-rw-r--r--tests/Agent.Windows.Tests/ReadmeOperationalTests.cs5
-rw-r--r--tests/Agent.Windows.Tests/SecurityPolicyTests.cs74
42 files changed, 1296 insertions, 250 deletions
diff --git a/AgentSandboxMcp.slnx b/AgentSandboxMcp.slnx
index 36c52dc..33c59f0 100644
--- a/AgentSandboxMcp.slnx
+++ b/AgentSandboxMcp.slnx
@@ -1,5 +1,7 @@
<Solution>
<Folder Name="/src/">
+ <Project Path="src/Agent.Admin/Agent.Admin.Client/Agent.Admin.Client.csproj" />
+ <Project Path="src/Agent.Admin/Agent.Admin.Host/Agent.Admin.Host.csproj" />
<Project Path="src/Agent.App/Agent.App.csproj" />
<Project Path="src/Agent.Core/Agent.Core.csproj" />
<Project Path="src/Agent.Mcp/Agent.Mcp.csproj" />
diff --git a/README.md b/README.md
index 94e9fb3..f0261db 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ Windows上でOllamaを利用し、AIエージェントのファイル操作とãƒ
| 項目 | 要件 |
| --- | --- |
| OS | Windows 11 x64 |
-| 実行権限 | 管理者権限(WFP操作に必要) |
+| 実行権限 | 通常ユーザー。Sandbox Processを初めて実行するTaskでWindows UACを表示 |
| Windows Service | Base Filtering Engine(BFE) |
| LLM | Ollama。配布設定の既定モデルは`deepseek-v4-flash:cloud` |
| Client | stdioまたはStreamable HTTP対応MCP Client |
@@ -105,13 +105,13 @@ Entry Processの子孫には個別のExecutable登録を要求しません。同
## 初回適用と起動
-管理者PowerShellで展開先へ移動します。まずYAMLから計算された権限マトリックスを確認します。
+通常のPowerShellで展開先へ移動します。まずYAMLから計算された権限マトリックスを確認します。`show`と`apply`はUACを表示しません。
```powershell
.\agent-mcp.exe show .\agent.yaml
```
-内容が正しければ設定全体を一度だけ適用します。Workspace/Mountの解決先、Executableの解決先とSHA-256、設定fingerprintが一つのスナップショットとして保存されます。
+内容が正しければ設定全体を一度だけ適用します。Workspace/Mountの解決先、Executableの解決先とSHA-256、設定fingerprintが一つのスナップショットとして保存され、固定Application Profileが通常ユーザー権限で準備されます。
```powershell
.\agent-mcp.exe apply .\agent.yaml
@@ -141,7 +141,7 @@ YAML、解決先、Executable hashのいずれかが変化すると起動はfail
| 構文 | 引数/option | 変更 | 用途 |
| --- | --- | --- | --- |
-| `agent-mcp [<config-path>]` | 任意の設定path | Task中の一時Sandbox Resourceのみ | 設定を検証し、管理者権限・BFE・WFPをpreflightしてServer起動 |
+| `agent-mcp [<config-path>]` | 任意の設定path | Task中の一時Sandbox Resourceのみ | read-onlyのPlatform/BFE/helper検査後に通常権限でServer起動 |
| `agent-mcp show [<config-path>]` | 任意の設定path | なし | 現在の希望状態、Resolved Path、hash、適用一致状態を表示 |
| `agent-mcp apply [<config-path>] [--yes]` | 任意の設定path、任意の`--yes` | Applied Configuration Storeをatomic置換 | マトリックスを一括確認して適用 |
@@ -159,7 +159,7 @@ YAMLは希望状態、Storeは利用者が確認した実体path・hashを含む
## MCP Client設定
-stdioではMCP ClientがServerを起動するため、MCP Clientを管理者権限で起動します。
+stdioでもMCP Clientは通常権限で起動します。ファイル操作だけのTaskではUACは発生しません。Task内で最初の`process_run`を行う直前だけ、WFPとloopback設定を担当する同梱Executableの昇格確認が表示されます。
```json
{
@@ -172,7 +172,7 @@ stdioではMCP ClientがServerを起動するため、MCP Clientを管理者権é
}
```
-HTTPではagent-mcp.exeを管理者権限で起動しますが、接続するMCP Clientの管理者権限は不要です。既定endpointは`http://127.0.0.1:8080/mcp`です。
+HTTPでも`agent-mcp.exe`と接続するMCP Clientを通常権限で起動します。既定endpointは`http://127.0.0.1:8080/mcp`です。Task時のUACを拒否すると当該Taskだけがfailedになり、Serverは継続します。crash後にpersistentなloopback設定のRecoveryが必要な場合だけ起動時にUACが表示され、拒否するとMCP受付前に終了します。
## MCP Tool
@@ -191,7 +191,7 @@ System Promptは`agent.user_system_prompt`、Skill directoryは`skills.directory
| --- | --- |
| `logs/application.log` | 起動とアプリケーションイベント |
| `logs/llm.jsonl` | Ollama call、token、Internal Tool結果 |
-| `logs/audit.jsonl` | ファイル、Process、Policy、ACL、WFP、Recovery、非対話apply |
+| `logs/audit.jsonl` | ファイル、Process、Policy、ACL、WFP、昇格、IPC拒否、Recovery、非対話apply |
| `logs/http.jsonl` | HTTP接続と処理結果 |
| `state/journals/` | crash recovery用Journal |
| `state/backups/` | 大容量編集のtransaction backup |
@@ -207,7 +207,8 @@ System Promptは`agent.user_system_prompt`、Skill directoryは`skills.directory
| --- | --- |
| `sandbox.configuration_not_applied` | `show`で全体を確認して`apply` |
| Executable更新後に起動できない | hash変更は自動承認しないため、`show`後に`apply` |
-| 管理者権限/WFP preflight失敗 | 管理者PowerShellで起動し、`Get-Service BFE`を確認 |
+| helper/BFE preflight失敗 | Release Bundleの`admin\agent-mcp-admin.exe`を復元し、`Get-Service BFE`を確認 |
+| Task実行時にUACを拒否 | 当該Taskはfailedになる。内容を確認し、必要ならTaskを再実行して昇格を承認 |
| Filesystem拒否 | WorkspaceまたはMountをYAMLへ追加し、再度`apply` |
| Network拒否 | `network.access`とendpointを見直し、再度`apply` |
@@ -224,4 +225,4 @@ dotnet test AgentSandboxMcp.slnx -c Release --no-build --no-restore
## ライセンス
-MIT \ No newline at end of file
+MIT
diff --git a/config/locales/en.json b/config/locales/en.json
index c8ec474..7877f20 100644
--- a/config/locales/en.json
+++ b/config/locales/en.json
@@ -1,12 +1,17 @@
{
"app.starting": "Starting agent.",
- "app.preflight_failed": "Sandbox preflight failed before bind: operation={operation}; error_code={error_code}; administrator={administrator}; BFE={bfe}; WFP transaction={transaction}; WFP add/delete={filters}. Start an elevated terminal and ensure the Base Filtering Engine service is running.",
+ "app.preflight_failed": "Sandbox preflight failed before bind: operation={operation}; error_code={error_code}; platform={platform}; application_profile={profile}; BFE={bfe}; helper={helper}; helper_version={version}. Restore the Release Bundle or start the Base Filtering Engine service.",
"app.ready.http": "Agent is ready for MCP requests. endpoint={endpoint}; protocol={protocol}; stateless={stateless}; legacy_initialize_fallback={fallback}; pid={pid}; logs={logs}",
"app.ready.stdio": "Agent stdio transport is ready. protocol={protocol}; pid={pid}; logs={logs}",
"config.invalid": "Invalid configuration: {message}",
"locale.invalid": "Invalid locale: {message}",
"cli.invalid": "Invalid command line: {message}",
"app.io_error": "Startup I/O failed: {message}",
+ "runtime.platform.elevation_cancelled": "Windows elevation was cancelled. The current Task failed; the MCP Server is still running. Detail: {message}",
+ "runtime.platform.elevation_failed": "Windows elevation failed. The current Task failed; the MCP Server is still running. Detail: {message}",
+ "runtime.platform.helper_incompatible": "The bundled elevated helper is missing or incompatible. Restore the Release Bundle. Detail: {message}",
+ "runtime.platform.ipc_rejected": "The elevated helper connection was rejected by the Sandbox Broker. Detail: {message}",
+ "runtime.platform.network_apply_failed": "The Windows network Task Resource could not be applied safely. Detail: {message}",
"show.configuration": "Configuration: {path}",
"show.applied": "Applied: {value}",
"show.fingerprint": "Fingerprint: {value}",
@@ -15,7 +20,7 @@
"show.not_applied": "not-applied",
"show.matched": "matched",
"show.changed": "changed",
- "show.platform": "Platform: not probed; startup performs the full privileged preflight",
+ "show.platform": "Platform: not probed; startup performs read-only platform and helper validation",
"show.filesystem": "FILESYSTEM",
"show.filesystem_columns": "TYPE\tLOGICAL\tRESOLVED\tACCESS\tSTATUS",
"show.execution": "EXECUTION",
@@ -49,6 +54,7 @@
"runtime.policy_repeated": "The same sandbox policy denial occurred again, so replanning was stopped.",
"runtime.replan_unchanged": "Replanning did not change the incomplete task plan.",
"runtime.policy_resolution.filesystem": "Review the Workspace or Mount and its required Filesystem Capability in YAML, then run agent-mcp apply.",
+ "runtime.policy_resolution.acl": "Use a root owned by the current user or grant that user WRITE_DAC. Elevation does not bypass this requirement.",
"runtime.policy_resolution.process": "Use an Alias declared in execution.entries, an allowed Runtime Component, or a development Workspace Artifact; review and apply changed identity in YAML.",
"runtime.policy_resolution.network": "Review the selected Network access mode and endpoint list; the Agent cannot widen it during a Task.",
"runtime.policy_resolution.default": "Review the YAML permission matrix with agent-mcp show, then run agent-mcp apply.",
diff --git a/config/locales/ja.json b/config/locales/ja.json
index 708bf47..96eb3a6 100644
--- a/config/locales/ja.json
+++ b/config/locales/ja.json
@@ -1,12 +1,17 @@
{
"app.starting": "エージェントを起動しています。",
- "app.preflight_failed": "待受開始前のSandbox診断に失敗しました: operation={operation}; error_code={error_code}; administrator={administrator}; BFE={bfe}; WFP transaction={transaction}; WFP add/delete={filters}。管理者権限のターミナルで起動し、Base Filtering Engineサービスを確認してください。",
+ "app.preflight_failed": "待受開始前のSandbox診断に失敗しました: operation={operation}; error_code={error_code}; platform={platform}; application_profile={profile}; BFE={bfe}; helper={helper}; helper_version={version}。Release Bundleを復元するかBase Filtering Engineサービスを確認してください。",
"app.ready.http": "MCPリクエストを受け付けられます。endpoint={endpoint}; protocol={protocol}; stateless={stateless}; legacy_initialize_fallback={fallback}; pid={pid}; logs={logs}",
"app.ready.stdio": "stdio transportでMCPリクエストを受け付けられます。protocol={protocol}; pid={pid}; logs={logs}",
"config.invalid": "設定が不正です: {message}",
"locale.invalid": "ロケールが不正です: {message}",
"cli.invalid": "コマンドラインが不正です: {message}",
"app.io_error": "起動時のI/O処理に失敗しました: {message}",
+ "runtime.platform.elevation_cancelled": "Windowsの権限昇格が拒否されました。当該Taskは失敗しましたがMCP Serverは継続しています。詳細: {message}",
+ "runtime.platform.elevation_failed": "Windowsの権限昇格に失敗しました。当該Taskは失敗しましたがMCP Serverは継続しています。詳細: {message}",
+ "runtime.platform.helper_incompatible": "同梱された昇格用Executableが存在しないか互換性がありません。Release Bundleを復元してください。詳細: {message}",
+ "runtime.platform.ipc_rejected": "昇格用Executableとの接続をSandbox Brokerが拒否しました。詳細: {message}",
+ "runtime.platform.network_apply_failed": "WindowsのNetwork Task Resourceを安全に適用できませんでした。詳細: {message}",
"show.configuration": "設定: {path}",
"show.applied": "適用済み: {value}",
"show.fingerprint": "fingerprint: {value}",
@@ -15,7 +20,7 @@
"show.not_applied": "未適用",
"show.matched": "一致",
"show.changed": "変更あり",
- "show.platform": "Windows preflight: 未実行(Server起動時に管理者権限で完全検査)",
+ "show.platform": "Windows preflight: 未実行(Server起動時にread-onlyのPlatform/helper検査)",
"show.filesystem": "FILESYSTEM権限",
"show.filesystem_columns": "種別\t論理PATH\t解決済みPATH\tアクセス\t状態",
"show.execution": "EXECUTION権限",
@@ -49,6 +54,7 @@
"runtime.policy_repeated": "同一のSandbox Policy拒否が再発したため、再計画を停止しました。",
"runtime.replan_unchanged": "再計画後も未完了のタスク計画が変更されませんでした。",
"runtime.policy_resolution.filesystem": "YAMLのWorkspaceまたはMountと必要なFilesystem Capabilityを確認し、agent-mcp applyを実行してください。",
+ "runtime.policy_resolution.acl": "現在のユーザーが所有するrootを使用するか、そのユーザーへWRITE_DACを付与してください。昇格でこの要件を迂回することはありません。",
"runtime.policy_resolution.process": "execution.entriesのAlias、許可されたRuntime Component、またはdevelopment Workspace Artifactを使用し、変更されたidentityはYAMLで確認してapplyしてください。",
"runtime.policy_resolution.network": "Network access modeとendpoint一覧を確認してください。AgentはTask中に許可範囲を拡張できません。",
"runtime.policy_resolution.default": "agent-mcp showでYAMLの権限マトリックスを確認し、agent-mcp applyを実行してください。",
diff --git a/doc/AuditReport.md b/doc/AuditReport.md
index 3cbed19..2155236 100644
--- a/doc/AuditReport.md
+++ b/doc/AuditReport.md
@@ -1,5 +1,9 @@
# v1再監査報告とSandbox Usability Revision状況
+> 2026-08-13オンデマンド昇格改定: 以下の管理者環境証跡は常時管理者方式の過去commitに対する履歴である。`asInvoker` main、Task単位helper、Named Pipe、dynamic WFP、persistent loopback Recoveryは最新commitで再検証するまでPASS根拠としない。
+
+2026-08-13 working treeでmainを`asInvoker`、昇格hostを`requireAdministrator/uiAccess=false`とし、WFP/loopback mutationを固定`admin/` hostへ分離した。通常tokenのHTTP/stdio MCPから実Taskを実行し、初回`process_run`時のUAC、logon SID DACL Named Pipe、LPAC Process Tree、dynamic WFP、ACL/WFP cleanupを確認した。Windows suiteは昇格子Processだけで79件PASS、Core 105件、Planner 10件、administrative boundary 4件がfail/skip 0。実UAC拒否、hostile peer実Process matrix、実helper crashからのstartup Recoveryは未再検証のため、TraceabilityではPARTIALを維持する。
+
> 2026-08-13 schema version 2追補: YAMLを唯一のSandbox desired stateとし、個別のExecutable Approval/Sandbox Resolution操作を廃止した。CLIはcommand省略のServer起動、`apply`、`show`に限定し、確認結果はconfig別Applied Configuration Storeへ一括保存する。本追補より前の管理者・publish証跡は旧schemaの履歴であり、schema version 2のPASS根拠ではない。最終検証はRelease build warning/error 0、Core 104件、Planner 10件、Windows Security/Recovery 56件、README operational 19件がfail/skip 0。publish HTTP MCPも`deepseek-v4-flash:cloud`でcompleted 3/3となり、cmd→cmd/curl/venv Python、Network none、終了後残留0を確認した。
## 基準
diff --git a/doc/Design.md b/doc/Design.md
index 80b5acc..5a8d0f0 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -93,8 +93,9 @@ Agent Runtime ─── Session Store (memory)
├── Less Privileged AppContainer (LPAC)
├── NTFS ACL
├── Job Object
- ├── WFP
- └── Named Pipe
+ └── Named Pipe ── Task単位runas Process
+ ├── WFP dynamic session
+ └── loopback exemption
│
▼
Sandbox Process Tree
@@ -121,6 +122,7 @@ src/
Agent.Core/ # Agent loop, session, prompt, ollama abstractions
Agent.Planner/ # TaskGraph, TokenPERT, TokenCPM
Agent.Windows/ # SandboxPolicy enforcement and Win32 interop
+ Agent.Admin/ # Sandbox Brokerの固定network処理を行う通常clientと昇格host
tests/
Agent.Core.Tests/
Agent.Planner.Tests/
@@ -140,6 +142,7 @@ state/
```text
Agent.App -> Agent.Mcp -> Agent.Core -> Agent.Planner
Agent.App -------------------------> Agent.Windows
+Agent.App / Agent.Windows ---------> Agent.Admin.Client
Agent.Core -> ISandbox/IToolExecutor interfaces
Agent.Windows implements those interfaces
```
@@ -234,7 +237,7 @@ result: <final natural-language answer; completed only>
### 5.8 起動preflightとHTTP request log
-- Appはtransport構築前に`WindowsPlatformCheck`を実行する。administrator/BFE/WFP probeが1つでも失敗した場合はhostをbuild/startせずfatalとする。
+- Appはtransport構築前に`WindowsPlatformCheck`を実行し、OS、Application Profile、BFE read-only open、固定Bundle pathの昇格用Executableとversionを検査する。Administrator判定、WFP transaction、filter add/delete probeは行わない。
- `StartAsync`完了後にだけ`IMcpHostObserver.Ready`を通知するため、「起動中」と「接続可能」を区別できる。
- HTTP middlewareはsocket endpointとrouting metadataだけをbounded in-memory queueへ`TryWrite`し、専用background writerがstderr、application log、`logs/http.jsonl`へ出力する。request threadはconsole/file I/Oを待たず、queue飽和時はrequestを止めずlog entryをdropして件数を警告する。
- forwarded header、credential header、query、payloadは保存しない。
@@ -539,7 +542,7 @@ network:
- reparse chainを最終targetまで解決しcycle、missing target、非local filesystemを拒否する。
- `agent-mcp apply [config] [--yes]`だけがApplied Configuration Storeを更新する。全解決・validation完了前、または確認前にStoreを変更しない。
- `agent-mcp [config]`は現在のYAMLを再解決・再hashし、Applied Configuration snapshotとの完全一致とWindows preflightを検証してからMCP受付を開始する。個別の`serve`と`check`commandは持たない。
-- `agent-mcp show [config]`はdesired/resolved/appliedの権限matrixをtext表示し、StoreやACL/WFPを変更しない。管理者権限、BFE、WFP add/deleteを伴う完全なWindows preflightはServer起動時だけ実行する。
+- `agent-mcp show [config]`はdesired/resolved/appliedの権限matrixをtext表示し、StoreやACL/WFPを変更せずUACを発生させない。`apply`は通常ユーザー権限で固定Application Profileを作成または確認してApplied Configurationを保存する。Server起動時は固定SIDの導出、BFE open、helper整合性のread-only preflightだけを行う。
- languageとtransportはYAMLだけから取得し、CLI overrideを持たない。
### 10.7 Effective Sandbox Policy
@@ -607,9 +610,13 @@ WFP実装は複雑な汎用Firewallにしない。設計上の変換規則だけ
5. DNS解決失敗はdeny。
6. Task終了でTask固有WFP filterを削除する。
7. LoopbackもEffective Network Policyに明示されない限り許可しない。LANをlocalへ含めない。
-具体的なWFP layer、sublayer、filter condition、transaction APIは実装時にCodexがWindows SDKを確認して決定し、Security Integration Testで仕様を検証する。
+WFP mutationはTask内の初回`process_run`直前に`runas`で起動する昇格Processが行う。Task間でProcessを再利用せず、dynamic sessionをTask終了まで保持する。session終了時にBFEがdynamic objectを削除するため、filter IDをRecovery Journalへ保存しない。
+
+`local`はLPAC network capability、`NetworkIsolationSetAppContainerConfig`によるAppContainer loopback送信許可、WFPによる`127.0.0.1`/`::1`の列挙port制限を組み合わせる。同APIは既存SID集合をGetして保持し、Application Profile SIDの追加前状態をRecovery Journalへ記録・復元する。Microsoft資料の`debugging purposes`という用途制約を既知制約とし、同API自体はSecurity Boundaryとしない。WFP permit単独、MSIX、独自proxyへfallbackしない。
### 11.6 Named Pipe
-Sandbox workerとのIPCが必要な場合はtask固有Named Pipeを使用し、DACLをApplication SIDとBrokerだけに限定する。TCP localhostを内部IPCに使わない。
+通常側がTask固有Named Pipe serverを明示DACL付きで作成し、昇格Processだけがclient接続する。DACLはlogon SIDとAdministratorsへ必要最小権限を与え、NETWORKをdenyし、default DACLを使用しない。通常側は`ShellExecuteEx/runas`相当で得たProcess IDと`GetNamedPipeClientProcessId`を照合し、昇格側もparent PIDと`GetNamedPipeServerProcessId`を照合する。
+
+protocolはversion、Task ID、固定Application SID、Network Access Mode、解決済みIP/port、loopback復元状態を持つ長さ上限付きmessageに限定する。任意Executable、command、path、Security Descriptor、Registry、WFP layer/sublayer/action、Configuration/Policyを表現できるfieldを持たない。TCP localhost、独自RPC、汎用Network proxyへ拡張しない。
---
## 12. ACL / Resource Transaction設計
### 12.1 ACIDから借用する性質
@@ -623,7 +630,7 @@ Sandbox workerとのIPCが必要な場合はtask固有Named Pipeを使用し、D
```text
CLEAN
-> PREPARING journal create
- -> APPLIED ACL/WFP/Job ready
+ -> APPLIED 初回process_run直前にACL/WFP/Job ready
-> RUNNING sandbox process start
-> CLEANING process stopped / task terminal
-> CLEAN all task resources removed
@@ -638,12 +645,13 @@ JournalはTaskごとのJSONとし、操作を実行する**前**にundoに必要
"task_id": "...",
"state": "APPLIED",
"acl_changes": [{"path":"...","change_id":"..."}],
- "wfp_filter_ids": [123],
+ "network_loopback_before": false,
"temp_paths": ["..."],
"created_at": "..."
}
```
起動時にjournalが存在する場合、MCP transport開始前にRecovery Managerが逆順でcleanupし、全項目の不存在/復元を検査してからjournalを削除する。
+ACL/file/tempだけなら通常権限で復旧する。persistentなloopback entryがあれば起動時だけ昇格Processを起動して復旧し、UAC拒否時はMCP受付前にfatal終了する。WFP dynamic entryの旧Journalはupgrade互換のno-opとしてclean化する。
---
## 13. File Transaction設計
### 13.1 基本フロー
@@ -699,7 +707,7 @@ Auditはappend-only JSONLを採用する。1 event = 1 line。
{"ts":"...","event":"file.write","task_id":"...","path":"...","before_sha256":"...","after_sha256":"..."}
{"ts":"...","event":"policy.deny","task_id":"...","kind":"network","target":"example.com:443","reason":"not_allowlisted"}
```
-最低event集合: `task.start`, `task.end`, `tool.call`, `process.start`, `process.exit`, `file.write`, `file.delete`, `policy.deny`, `network.rule.apply`, `network.rule.remove`, `recovery.start`, `recovery.end`。各eventは可能な限りsession ID、task ID、内部error codeを持つ。
+最低event集合: `task.start`, `task.end`, `tool.call`, `process.start`, `process.exit`, `file.write`, `file.delete`, `policy.deny`, `network.rule.apply`, `network.rule.remove`, `recovery.start`, `recovery.end`, `sandbox.elevation.request`, `sandbox.elevation.success`, `sandbox.elevation.deny`, `sandbox.ipc.reject`。各eventは可能な限りsession ID、task ID、内部error codeを持つ。pipe名、IPC payload全文、UAC credentialは記録しない。
---
## 16. Recovery・終了設計
### 16.1 graceful stop
@@ -790,6 +798,11 @@ closed Sessionは再利用しない。プロセス再起動後に以前のSessio
| 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の負方向集合をすべて検証 |
+| ST-28 | 通常tokenでのstdio/HTTP、show/apply、file-only Task | ServerとTaskが成功しUACが発生しない |
+| ST-29 | Taskごとの初回/後続process_runとUAC拒否 | 初回だけ昇格し、同一Task内でhelperを再利用する。拒否は当該Taskだけfailed |
+| ST-30 | helper crash、main crash、startup Recovery | WFP残留0、loopbackを元状態へ復元、Recovery UAC拒否はbind前fatal |
+| ST-31 | Named Pipeのremote/別PID/別session/version/size/reconnect/double-apply | すべて拒否し、固定network操作以外をschemaで表現不能 |
+| ST-32 | main/helper manifest、bundle path/version、native call graph | asInvoker/requireAdministrator、固定admin配置、WFP/loopback mutationがhelper側だけに存在 |
---
## 20. エラー分類
| Category | 例 | Task結果 | Retry |
@@ -818,8 +831,9 @@ errorは内部codeとlocalized messageを分離し、ログ/auditにはcode、MC
| 10 | Release/Operational E2E | self-contained Bundle単独起動、stdio/HTTP実利用 |
### 21.1 Release設計
-- `dotnet publish -c Release -r win-x64 --self-contained true` のfolder publishを配布元とする。
+- mainと昇格hostを同じversion/RIDで`dotnet publish -c Release -r win-x64 --self-contained true`し、mainをBundle root、昇格hostを固定`admin/` subdirectoryへ配置する。
- Bundleはversion付きZIP、展開済みdirectory、file manifest、SHA-256 checksumから成る。
+- main manifestは`asInvoker`、`admin/agent-mcp-admin.exe` manifestは`requireAdministrator`かつ`uiAccess=false`とする。mainは`AppContext.BaseDirectory/admin/agent-mcp-admin.exe`だけを参照し、PATH検索やConfigurationによる差替えを許さない。
- 起動時の相対pathは実行ファイルdirectoryを基準に解決し、ソースツリーを参照しない。
- 現行Operational E2Eは配布既定の`deepseek-v4-flash:cloud`を使用する。rate limit、接続失敗、model unavailableをapplication PASSとして扱わない。
---
diff --git a/doc/Specifications.md b/doc/Specifications.md
index fb1de12..dcc2425 100644
--- a/doc/Specifications.md
+++ b/doc/Specifications.md
@@ -188,7 +188,7 @@ command省略時はYAMLの`mcp.transport`と`application.language`を使用しã�
`apply`はbase/local merge後のYAML全体、Workspace、Mount、Execution Entry、Network、Resourceを解決し、ExecutableのResolved PathとSHA-256を含む権限差分を一度だけ表示する。全検証成功後、利用者が承認した場合だけconfig別Applied Configuration Storeをatomic replaceする。途中失敗時は以前の適用状態を維持する。`--yes`は非対話適用だけに使用でき、Auditへ記録する。
-`show`はread-onlyとし、desired/resolved/applied/effectiveの状態とFilesystem/Execution/Network/Resource権限マトリックスを一つのtext表示で返す。WFP filter追加削除を伴う完全なPlatform preflightは起動時に必ず実施する。MCP ClientおよびLLMから`apply`を呼び出せない。
+`show`はread-onlyとし、desired/resolved/applied/effectiveの状態とFilesystem/Execution/Network/Resource権限マトリックスを一つのtext表示で返す。`show`と`apply`はUACを発生させない。MCP ClientおよびLLMから`apply`を呼び出せない。
旧`serve`、`check`、`executable *`、`sandbox *`、`--config`、`--lang`、`--transport`、`--path`、`--runtime-root`、`--no-runtime`、`--format`は受け付けず、schema version 2の移行診断を返す。
@@ -206,7 +206,8 @@ command省略時はYAMLの`mcp.transport`と`application.language`を使用しã�
### 5.8 起動・HTTP観測性
-- command省略のServer起動はbind前に管理者権限、BFE、WFP transaction、filter add/deleteを検査し、不足時は具体的なerrorと対処をstderrへ出してexit code 2で終了しなければならない。
+- `agent-mcp.exe`は`asInvoker`で通常ユーザー権限のまま起動し、MCP Clientに管理者権限を要求しない。
+- command省略のServer起動はbind前にOS、Configuration、Applied Configuration、Effective Sandbox Policy、Application Profile、BFE、昇格用Executableの存在・互換version、Recovery Journalをread-only検査する。WFPを書き換えるprobeを起動時に行わない。
- ready messageは実際のtransport開始後だけ出力し、HTTPではendpoint/protocol/stateless/PID/log path、stdioではprotocol/PID/log pathを含める。
- HTTP request logは実socketのremote/local endpoint、method、path、status、duration、標準MCP routing headerを記録可能とする。
- `Authorization`、`Cookie`、query、body、prompt、画像、Base64をrequest logへ記録してはならず、`X-Forwarded-For`を接続元として信用してはならない。
@@ -444,6 +445,9 @@ Resource Profileは`constrained = 1024 MiB/4 Process/300秒`、`normal = 4096 Mi
- Resolved `%SystemRoot%/System32`をOS Runtime RootとしてSandbox Process Treeへread/execute公開する。Brokerからの直接Entry起動は別途Executable Approvalを要求する。
- Task終了時にApplication Profileを削除しない。
- Task固有ACL、WFP filter、Job Object、一時ファイル等はTask Resourceとして管理する。
+- LPAC Process Treeに必要なTask ResourceはTask内の初回`process_run`直前に適用する。Processを使用しないTaskはUACを発生させない。
+- WFP書込と現在昇格して行うsystem-wide loopback設定だけをTask単位で昇格し、ACL、LPAC Process生成、Job Object、Executable Approvalは通常権限で処理する。
+- Task時のUAC拒否または昇格用Executable起動失敗では当該Taskだけをfailedとし、部分適用ResourceをrollbackしてServerを継続する。
- Task Resourceは正常終了・失敗・cancelのいずれでも残留させない。
- crashで残留可能性がある変更はRecovery Journalにより次回起動時に回収する。
- MCP受付開始前にRecoveryを完了し、clean stateを確認しなければならない。
@@ -490,7 +494,7 @@ Resource Profileは`constrained = 1024 MiB/4 Process/300秒`、`normal = 4096 Mi
| --- | --- |
| Application Log | stderrに出力可能。stdio時stdoutへログを出してはならない。ファイルにも保存する。 |
| LLM Log | promptとresponseを記録する。call、Session、Task、Planner Task、phase、iteration、replan、model、token usageを相関できなければならない。secretを継承しない前提でも、ログ出力先は保護する。 |
-| Audit Log | 通常ログと分離する。Task lifecycle、Internal Tool Call、Sandbox Process、秘密情報を除いたargument metadata、exit code、file write、delete、denied access、network rule、recoveryをSession/Taskと相関して記録する。argument本文、画像、Base64、credentialは記録しない。 |
+| Audit Log | 通常ログと分離する。Task lifecycle、Internal Tool Call、Sandbox Process、秘密情報を除いたargument metadata、exit code、file write、delete、denied access、network rule、昇格要求・結果、IPC拒否、recoveryをSession/Taskと相関して記録する。argument本文、画像、Base64、credential、pipe認証情報、IPC payload全文は記録しない。 |
| Token Metrics | Task/Call単位のinput/output/total tokenを記録する。 |
---
@@ -506,6 +510,8 @@ Resource Profileは`constrained = 1024 MiB/4 Process/300秒`、`normal = 4096 Mi
| REC-05 | Streamable HTTP server停止/明示cancel | 対象Taskを安全停止する。stateless HTTPのrequest完了やsocket切断はAgent Session終了とみなさない。 |
| REC-06 | crash後起動 | Recovery Journalに基づき残留Task Resourceをcleanにしてからserveする。 |
| REC-07 | cleanup失敗 | serveを開始せずfatal errorとする。 |
+| REC-08 | Task時UAC拒否 | 当該Taskをfailedとし、rollback後もMCP Serverを継続する。 |
+| REC-09 | 起動時RecoveryのUAC拒否 | Recovery未完了としてMCP受付前にfatal終了する。 |
---
@@ -526,6 +532,10 @@ Resource Profileは`constrained = 1024 MiB/4 Process/300秒`、`normal = 4096 Mi
| SEC-INV-11 | Recovery未完了状態でMCP受付を開始しない。 |
| SEC-INV-12 | stdio stdoutにはMCP protocol以外を書かない。 |
| SEC-INV-13 | Sandbox Process TreeのどのProcessもEffective Sandbox Policy外のresourceまたはcapabilityを取得できない。 |
+| SEC-INV-14 | MCP Server本体は管理者tokenを必要とせず、LLM、MCP Client、Internal Toolは昇格用Executableへ直接要求できない。 |
+| SEC-INV-15 | 昇格側は任意Executable、command line、Filesystem path、Security Descriptor、Registry、User Sandbox Configuration、Effective Sandbox Policyを操作できない。 |
+| SEC-INV-16 | 昇格側のnetwork変更は固定Application Profileと確定済みEffective Network Policyから導出されるものに限定する。 |
+| SEC-INV-17 | 未許可ProcessはTask固有Named Pipeを利用できず、昇格Process異常終了後にWFP dynamic objectを残さない。 |
---
@@ -571,6 +581,11 @@ Resource Profileは`constrained = 1024 MiB/4 Process/300秒`、`normal = 4096 Mi
| AT-18 | ja/en切替がCLI、MCP応答、LLM回答へ反映される。 |
| AT-19 | 追加localeがコード変更なしでロードされる。 |
| AT-20 | 未知locale keyまたは壊れたJSONで起動/checkが失敗する。 |
+| AT-23 | `agent-mcp.exe`とstdio/HTTP MCP Clientを通常tokenで起動でき、`show`、`apply`、Processを使わないTaskではUACが発生しない。 |
+| AT-24 | 初回`process_run`直前だけTask専用の昇格用Executableが起動し、同一Taskでは再利用され、次Taskへは再利用されない。UAC拒否またはhelper失敗は当該Taskだけをfailedにする。 |
+| AT-25 | mainは`asInvoker`、昇格用Executableは`requireAdministrator`かつ`uiAccess=false`であり、Release Bundleの固定`admin/`配置以外を起動しない。 |
+| AT-26 | Named Pipeのremote接続、peer PID不一致、protocol/version不一致、過大message、再接続、二重network適用を拒否する。 |
+| AT-27 | helper異常終了後にdynamic WFP objectが残らず、persistent loopback変更はRecovery Journalから復元される。Recovery時UAC拒否はMCP受付前fatalとなる。 |
### 20.1 Operational E2E
diff --git a/plans/ImplementationPlan.md b/plans/ImplementationPlan.md
index 4ec765f..fc101c7 100644
--- a/plans/ImplementationPlan.md
+++ b/plans/ImplementationPlan.md
@@ -1,5 +1,7 @@
# AI Agent Sandbox MCP Server — 実装・監査作業計画
+> 2026-08-13オンデマンド昇格追補: Server全体の常時管理者起動を廃止する。`agent-mcp.exe`は`asInvoker`で起動し、Task内の初回`process_run`直前にWFPとloopback exemptionだけをTask単位の`runas` Processへ移す。ACL、LPAC、Job、Executable Approvalは通常側に維持する。WFP dynamic objectのfilter IDは新規Recovery Journalへ保存せず、persistentなloopback状態だけを既存Journalで復元する。旧管理者起動を前提とするPhase完了記録は当時のcommitに対する履歴である。
+
> Current status (2026-08-13): Sandbox Configuration schema version 2を破壊的に導入した。YAMLを唯一のdesired stateとし、CLIはServer起動、`apply`、`show`へ限定する。個別Executable Approval StoreとSandbox Resolution Storeは廃止し、config別Applied Configuration Storeへ統合する。旧完了報告と旧schemaの証跡は履歴であり、schema version 2のPASS根拠には使用しない。
**文書名:** `ImplementationPlan.md`
diff --git a/plans/RequirementTraceability.md b/plans/RequirementTraceability.md
index 1aab2a2..241866a 100644
--- a/plans/RequirementTraceability.md
+++ b/plans/RequirementTraceability.md
@@ -1,5 +1,13 @@
# Requirement Traceability
+> 2026-08-13オンデマンド昇格追補: 起動、Task Resource、Recovery、Security Invariantの証跡は`asInvoker` main、Task単位helper、明示DACL Named Pipe、dynamic WFP、loopback復元の最新検証へ置換する。これ以前の「administratorでServer起動」のPASSは履歴であり、現行RequirementのPASS根拠に使用しない。
+
+| Requirement | 実装 | 検証 | 状態 |
+| --- | --- | --- | --- |
+| 非昇格Server / Task時昇格 | main/admin manifest、`WindowsPlatformCheck`、遅延`process_run`初期化 | embedded manifest、通常token stdio/HTTP、実UAC、file-only無昇格、terminal error unit | PARTIAL — 実UAC拒否操作は未再検証 |
+| WFP / loopback Process境界 | `Agent.Admin.Host`、固定Named Pipe protocol | static call graph、strict schema、PID検証、none実HTTP、Windows suite | PARTIAL — hostile peer/remote pipeの実Process matrixは未再検証 |
+| Recovery | 既存`TaskResourceJournal`と通常/昇格Recovery振分け | Windows crash matrix、legacy `wfp.dynamic`、loopback helper unit | PARTIAL — 実helper crashと実UAC startup Recoveryは未再検証 |
+
Sandbox Configuration schema version 2後の現行実装を対象とする。2026-08-13以前の結果は`plans/progress/`と本書末尾に履歴として保持するが、schema version 2の現行PASS判定には使用しない。
状態は`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/`に保存する。
@@ -57,6 +65,13 @@ Internal Tool testは件数を仕様としてassertしない。`Design.md`のToo
| Verification | Date / privilege | Result |
|---|---|---|
+| on-demand elevation Release build | 2026-08-13 / non-elevated | warning 0、error 0 |
+| on-demand elevation Core / Planner | 2026-08-13 / non-elevated | Core 105 PASS、Planner 10 PASS、fail/skip 0 |
+| administrative boundary tests | 2026-08-13 / non-elevated | fixed IPC schema、過大/未知field拒否、manifest/native call ownershipの4件PASS、fail/skip 0 |
+| on-demand elevation Windows suite | 2026-08-13 / Windows Security childのみadministrator | 79 PASS、fail/skip 0。共有Application Profile/WFP状態を直列実行 |
+| published HTTP MCP on-demand elevation | 2026-08-13 / main standard-user、helper Task時UAC / `deepseek-v4-flash:cloud` | completed 3/3。cmd→cmd/curl/Python 3.14、Network none、昇格request/success、WFP/ACL cleanup、helper残留0 |
+| published stdio Operational E2E | 2026-08-13 / main standard-user、helper Task時UAC / `deepseek-v4-flash:cloud` | completed 4/4。複雑Python実装、9 unittest、Python subprocess、cleanup PASS |
+| on-demand elevation manifests / Bundle | 2026-08-13 / fresh extraction | main `asInvoker`、helper `requireAdministrator/uiAccess=false`、固定`admin/`配置、local資産保持を確認。ZIP SHA-256 `59c6d110c919cb616812272cb3189b03e5a0cb1b42178942cc23748a57d3d0e8` |
| schema v2 Release build | 2026-08-13 / non-elevated | warning 0、error 0 |
| schema v2 Agent.Core.Tests / Planner.Tests | 2026-08-13 / non-elevated | Core 104 PASS、Planner 10 PASS、fail/skip 0 |
| schema v2 Windows Security/Recovery + README operational | 2026-08-13 / administrator | 環境非依存56件と実環境19件の計75 PASS、fail/skip 0。LPAC/ACL/Job/WFP、PowerShell/cmd/venv Python、Network、ACL monkeyを含む |
diff --git a/scripts/Publish-Release.ps1 b/scripts/Publish-Release.ps1
index 41bdfa3..3ab7bda 100644
--- a/scripts/Publish-Release.ps1
+++ b/scripts/Publish-Release.ps1
@@ -24,13 +24,19 @@ if (Test-Path -LiteralPath $checksum) { Remove-Item -LiteralPath $checksum -Forc
New-Item -ItemType Directory -Path $stagingBundle | Out-Null
$appProject = Join-Path $repository 'src\Agent.App\Agent.App.csproj'
+$adminProject = Join-Path $repository 'src\Agent.Admin\Agent.Admin.Host\Agent.Admin.Host.csproj'
dotnet restore $appProject -r win-x64 --locked-mode
if ($LASTEXITCODE -ne 0) { throw "win-x64 locked restore failed with exit code $LASTEXITCODE" }
+dotnet restore $adminProject -r win-x64 --locked-mode
+if ($LASTEXITCODE -ne 0) { throw "win-x64 admin locked restore failed with exit code $LASTEXITCODE" }
dotnet publish $appProject -c Release -r win-x64 --self-contained true --no-restore --output $stagingBundle -p:Version=$Version
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed with exit code $LASTEXITCODE" }
+$adminBundle = Join-Path $stagingBundle 'admin'
+dotnet publish $adminProject -c Release -r win-x64 --self-contained true --no-restore --output $adminBundle -p:Version=$Version
+if ($LASTEXITCODE -ne 0) { throw "admin dotnet publish failed with exit code $LASTEXITCODE" }
$required = @(
- 'agent-mcp.exe', 'agent.yaml', 'config\locales\en.json', 'config\locales\ja.json',
+ 'agent-mcp.exe', 'admin\agent-mcp-admin.exe', 'agent.yaml', 'config\locales\en.json', 'config\locales\ja.json',
'prompts\user-system.md', 'skills', 'README.md'
)
foreach ($relative in $required) {
diff --git a/scripts/Run-Final-ElevatedVerification.ps1 b/scripts/Run-Final-ElevatedVerification.ps1
index 71135c2..1162da0 100644
--- a/scripts/Run-Final-ElevatedVerification.ps1
+++ b/scripts/Run-Final-ElevatedVerification.ps1
@@ -1,5 +1,8 @@
param(
- [string]$EvidenceRoot = 'artifacts\evidence\final-elevated'
+ [string]$EvidenceRoot = 'artifacts\evidence\final-elevated',
+ [string]$TestRoot = 'C:\tmp',
+ [string]$VenvPython = 'C:\tmp\.venv\Scripts\python.exe',
+ [switch]$WindowsSecurityChild
)
$ErrorActionPreference = 'Continue'
@@ -23,7 +26,6 @@ $administrator = ([Security.Principal.WindowsPrincipal][Security.Principal.Windo
"os=$([Environment]::OSVersion.VersionString)"
"dotnet=$(& dotnet --version)"
) | Set-Content -LiteralPath $metadata -Encoding utf8NoBOM
-if (-not $administrator) { throw 'Final elevated verification must run as Windows Administrator.' }
function Invoke-VerificationStep {
param([string]$Name, [scriptblock]$Action)
@@ -47,10 +49,33 @@ $windowsTests = Join-Path $repository 'build\Release\Agent.Windows.Tests\net10.0
$coreTests = Join-Path $repository 'build\Release\Agent.Core.Tests\net10.0-windows10.0.26100.0\Agent.Core.Tests.exe'
$plannerTests = Join-Path $repository 'build\Release\Agent.Planner.Tests\net10.0\Agent.Planner.Tests.exe'
+if ($WindowsSecurityChild) {
+ if (-not $administrator) { Write-Error 'Windows Security child did not receive an administrator token.'; exit 2 }
+ $env:AGENT_MCP_MONKEY_ROOT = (Resolve-Path -LiteralPath $TestRoot).Path
+ $env:AGENT_MCP_TEST_VENV_PYTHON = (Resolve-Path -LiteralPath $VenvPython).Path
+ $env:AGENT_MCP_TEST_POWERSHELL = (Get-Command pwsh.exe -ErrorAction Stop).Source
+ $env:AGENT_MCP_TEST_POWERSHELL_RUNTIME = Split-Path $env:AGENT_MCP_TEST_POWERSHELL
+ $env:AGENT_MCP_TEST_CMD = $env:ComSpec
+ $childLog = Join-Path $evidence 'windows-security-tests-elevated.log'
+ # The suite intentionally shares one fixed Application Profile and global loopback state.
+ # Serialize it so crash/recovery cases cannot race another WFP/loopback case.
+ & $windowsTests -parallel none -failSkips *>&1 | Tee-Object -FilePath $childLog
+ exit $LASTEXITCODE
+}
+if ($administrator) {
+ throw 'Run the final verification orchestrator with a standard-user token. It elevates only the Windows Security test child.'
+}
+
Invoke-VerificationStep 'bundle-show' {
& (Join-Path $bundle 'agent-mcp.exe') show (Join-Path $bundle 'agent.yaml')
}
-Invoke-VerificationStep 'windows-security-tests' { & $windowsTests }
+Invoke-VerificationStep 'windows-security-tests' {
+ $pwsh = (Get-Command pwsh.exe -ErrorAction Stop).Source
+ $child = Start-Process -FilePath $pwsh -Verb RunAs -WindowStyle Hidden -PassThru -Wait -ArgumentList @(
+ '-NoProfile', '-File', $PSCommandPath, '-EvidenceRoot', $EvidenceRoot,
+ '-TestRoot', $TestRoot, '-VenvPython', $VenvPython, '-WindowsSecurityChild')
+ if ($child.ExitCode -ne 0) { throw "Elevated Windows Security test child failed with exit code $($child.ExitCode)." }
+}
Invoke-VerificationStep 'core-unit-integration-tests' { & $coreTests }
Invoke-VerificationStep 'planner-unit-tests' { & $plannerTests }
Invoke-VerificationStep 'e2e-01-stdio' {
diff --git a/scripts/Verify-PublishedHttp.ps1 b/scripts/Verify-PublishedHttp.ps1
index 7dd5fe2..414e749 100644
--- a/scripts/Verify-PublishedHttp.ps1
+++ b/scripts/Verify-PublishedHttp.ps1
@@ -18,9 +18,6 @@ $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'
diff --git a/scripts/Verify-Readme.ps1 b/scripts/Verify-Readme.ps1
index 42c8b8f..c9a1a99 100644
--- a/scripts/Verify-Readme.ps1
+++ b/scripts/Verify-Readme.ps1
@@ -11,8 +11,6 @@ $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 operational verification must run as Windows Administrator.' }
$pwshPath = (Get-Command pwsh.exe -ErrorAction Stop).Source
$cmdPath = $env:ComSpec
diff --git a/src/Agent.Admin/Agent.Admin.Client/AdminProtocol.cs b/src/Agent.Admin/Agent.Admin.Client/AdminProtocol.cs
new file mode 100644
index 0000000..679ccab
--- /dev/null
+++ b/src/Agent.Admin/Agent.Admin.Client/AdminProtocol.cs
@@ -0,0 +1,61 @@
+using System.Buffers.Binary;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Agent.Admin.Client;
+
+public sealed record AdminNetworkEndpoint(string Address, int Port);
+
+public sealed record AdminRequest(
+ int Version,
+ string Operation,
+ Guid TaskId,
+ string? ApplicationSid = null,
+ string? NetworkAccess = null,
+ IReadOnlyList<AdminNetworkEndpoint>? Endpoints = null,
+ bool? OriginallyLoopbackExempt = null);
+
+public sealed record AdminResponse(bool Success, string? ErrorCode = null, string? Message = null, bool? LoopbackExempt = null);
+
+public static class AdminProtocol
+{
+ public const int Version = 1;
+ public const int MaxMessageBytes = 64 * 1024;
+ private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
+ {
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
+ };
+
+ public static async Task WriteAsync<T>(Stream stream, T value, CancellationToken cancellationToken)
+ {
+ byte[] payload = JsonSerializer.SerializeToUtf8Bytes(value, Json);
+ if (payload.Length is <= 0 or > MaxMessageBytes) throw new InvalidDataException("Administrative pipe message exceeds the fixed size limit.");
+ byte[] header = new byte[sizeof(int)];
+ BinaryPrimitives.WriteInt32LittleEndian(header, payload.Length);
+ await stream.WriteAsync(header, cancellationToken).ConfigureAwait(false);
+ await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
+ await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ public static async Task<T> ReadAsync<T>(Stream stream, CancellationToken cancellationToken)
+ {
+ byte[] header = new byte[sizeof(int)];
+ await ReadExactlyAsync(stream, header, cancellationToken).ConfigureAwait(false);
+ int length = BinaryPrimitives.ReadInt32LittleEndian(header);
+ if (length is <= 0 or > MaxMessageBytes) throw new InvalidDataException("Administrative pipe message has an invalid size.");
+ byte[] payload = new byte[length];
+ await ReadExactlyAsync(stream, payload, cancellationToken).ConfigureAwait(false);
+ return JsonSerializer.Deserialize<T>(payload, Json) ?? throw new InvalidDataException("Administrative pipe message is empty.");
+ }
+
+ private static async Task ReadExactlyAsync(Stream stream, Memory<byte> buffer, CancellationToken cancellationToken)
+ {
+ int offset = 0;
+ while (offset < buffer.Length)
+ {
+ int read = await stream.ReadAsync(buffer[offset..], cancellationToken).ConfigureAwait(false);
+ if (read == 0) throw new EndOfStreamException("Administrative pipe disconnected.");
+ offset += read;
+ }
+ }
+}
diff --git a/src/Agent.Admin/Agent.Admin.Client/Agent.Admin.Client.csproj b/src/Agent.Admin/Agent.Admin.Client/Agent.Admin.Client.csproj
new file mode 100644
index 0000000..019c9d4
--- /dev/null
+++ b/src/Agent.Admin/Agent.Admin.Client/Agent.Admin.Client.csproj
@@ -0,0 +1,7 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
+ <AssemblyName>Agent.Admin.Client</AssemblyName>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+ </PropertyGroup>
+</Project>
diff --git a/src/Agent.Admin/Agent.Admin.Client/ElevatedNetworkSession.cs b/src/Agent.Admin/Agent.Admin.Client/ElevatedNetworkSession.cs
new file mode 100644
index 0000000..57339dc
--- /dev/null
+++ b/src/Agent.Admin/Agent.Admin.Client/ElevatedNetworkSession.cs
@@ -0,0 +1,237 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO.Pipes;
+using System.Runtime.InteropServices;
+using System.Security.AccessControl;
+using System.Security.Principal;
+using Microsoft.Win32.SafeHandles;
+
+namespace Agent.Admin.Client;
+
+public sealed class AdminOperationException(string code, string message, Exception? inner = null) : Exception(message, inner)
+{
+ public string Code { get; } = code;
+}
+
+public interface IElevatedNetworkSession : IAsyncDisposable
+{
+ Task<bool> InspectLoopbackAsync(string applicationSid, CancellationToken cancellationToken);
+ Task ApplyAsync(string applicationSid, string networkAccess, IReadOnlyList<AdminNetworkEndpoint> endpoints,
+ bool originallyLoopbackExempt, CancellationToken cancellationToken);
+ Task RecoverLoopbackAsync(string applicationSid, bool originallyLoopbackExempt, CancellationToken cancellationToken);
+ Task CleanupAsync(CancellationToken cancellationToken);
+}
+
+public interface IElevatedNetworkSessionFactory
+{
+ Task<IElevatedNetworkSession> StartAsync(Guid taskId, CancellationToken cancellationToken);
+}
+
+public sealed partial class ElevatedNetworkSessionFactory(string helperPath) : IElevatedNetworkSessionFactory
+{
+ private const uint PipeAccessDuplex = 0x00000003;
+ private const uint FileFlagOverlapped = 0x40000000;
+ private const uint FileFlagWriteThrough = 0x80000000;
+ private const uint PipeRejectRemoteClients = 0x00000008;
+
+ public async Task<IElevatedNetworkSession> StartAsync(Guid taskId, CancellationToken cancellationToken)
+ {
+ string canonicalHelper = Path.GetFullPath(helperPath);
+ if (!File.Exists(canonicalHelper))
+ throw new AdminOperationException("sandbox.helper_incompatible", $"Elevated helper does not exist at the fixed Bundle path '{canonicalHelper}'.");
+
+ string pipeName = $"AgentSandboxMcp-{Environment.ProcessId}-{Guid.NewGuid():N}";
+ using WindowsIdentity identity = WindowsIdentity.GetCurrent();
+ SecurityIdentifier logon = GetLogonSid(identity);
+ var security = new PipeSecurity();
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+ security.AddAccessRule(new PipeAccessRule(logon, PipeAccessRights.ReadWrite, AccessControlType.Allow));
+ security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
+ PipeAccessRights.ReadWrite, AccessControlType.Allow));
+ security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.NetworkSid, null),
+ PipeAccessRights.FullControl, AccessControlType.Deny));
+
+ NamedPipeServerStream pipe = CreateLocalPipe(pipeName, security);
+ Process? helper = null;
+ try
+ {
+ var start = new ProcessStartInfo
+ {
+ FileName = canonicalHelper,
+ Arguments = $"--pipe {Quote(pipeName)} --parent-pid {Environment.ProcessId} --task {taskId:N} --protocol {AdminProtocol.Version}",
+ UseShellExecute = true,
+ Verb = "runas",
+ WorkingDirectory = Path.GetDirectoryName(canonicalHelper)!
+ };
+ try { helper = Process.Start(start); }
+ catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
+ {
+ throw new AdminOperationException("sandbox.elevation_cancelled", "The Windows elevation request was cancelled.", ex);
+ }
+ catch (Exception ex) when (ex is Win32Exception or InvalidOperationException)
+ {
+ throw new AdminOperationException("sandbox.elevation_failed", $"The elevated helper could not be started: {ex.Message}", ex);
+ }
+ if (helper is null) throw new AdminOperationException("sandbox.elevation_failed", "The elevated helper did not return a Process handle.");
+
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ Task connection = pipe.WaitForConnectionAsync(linked.Token);
+ Task exit = helper.WaitForExitAsync(linked.Token);
+ Task timeout = Task.Delay(TimeSpan.FromSeconds(30), linked.Token);
+ Task completed = await Task.WhenAny(connection, exit, timeout).ConfigureAwait(false);
+ if (completed == exit)
+ throw new AdminOperationException("sandbox.elevation_failed", $"The elevated helper exited before connecting (exit code {helper.ExitCode}).");
+ if (completed == timeout)
+ throw new AdminOperationException("sandbox.elevation_failed", "The elevated helper did not connect within 30 seconds.");
+ await connection.ConfigureAwait(false);
+ linked.Cancel();
+
+ if (!Native.GetNamedPipeClientProcessId(pipe.SafePipeHandle, out uint clientPid) || clientPid != helper.Id || helper.HasExited)
+ throw new AdminOperationException("sandbox.ipc_rejected", "The Named Pipe peer does not match the elevated Process started for this Task.");
+ await AdminProtocol.WriteAsync(pipe, new AdminRequest(AdminProtocol.Version, "hello", taskId), cancellationToken).ConfigureAwait(false);
+ AdminResponse hello = await AdminProtocol.ReadAsync<AdminResponse>(pipe, cancellationToken).ConfigureAwait(false);
+ EnsureSuccess(hello);
+ return new ElevatedNetworkSession(pipe, helper, taskId);
+ }
+ catch
+ {
+ pipe.Dispose();
+ helper?.Dispose();
+ throw;
+ }
+ }
+
+ private static string Quote(string value) => '"' + value.Replace("\"", "\\\"") + '"';
+
+ private static SecurityIdentifier GetLogonSid(WindowsIdentity identity)
+ {
+ const int tokenLogonSid = 28;
+ _ = Native.GetTokenInformation(identity.Token, tokenLogonSid, 0, 0, out uint length);
+ if (length < (uint)(IntPtr.Size * 2))
+ throw new AdminOperationException("sandbox.ipc_rejected", "The invoking logon SID size is invalid.");
+ nint buffer = Marshal.AllocHGlobal(checked((int)length));
+ try
+ {
+ if (!Native.GetTokenInformation(identity.Token, tokenLogonSid, buffer, length, out _))
+ throw new AdminOperationException("sandbox.ipc_rejected",
+ $"The invoking logon SID is unavailable: {new Win32Exception(Marshal.GetLastPInvokeError()).Message}");
+ // TOKEN_GROUPS contains a 32-bit count followed by pointer-aligned SID_AND_ATTRIBUTES.
+ nint sid = Marshal.ReadIntPtr(buffer, IntPtr.Size);
+ return new SecurityIdentifier(sid);
+ }
+ finally { Marshal.FreeHGlobal(buffer); }
+ }
+
+ private static NamedPipeServerStream CreateLocalPipe(string pipeName, PipeSecurity security)
+ {
+ byte[] descriptor = security.GetSecurityDescriptorBinaryForm();
+ GCHandle pinned = GCHandle.Alloc(descriptor, GCHandleType.Pinned);
+ try
+ {
+ var attributes = new Native.SecurityAttributes
+ {
+ Length = Marshal.SizeOf<Native.SecurityAttributes>(),
+ SecurityDescriptor = pinned.AddrOfPinnedObject(),
+ InheritHandle = 0
+ };
+ SafePipeHandle handle = Native.CreateNamedPipe($@"\\.\pipe\{pipeName}",
+ PipeAccessDuplex | FileFlagOverlapped | FileFlagWriteThrough,
+ PipeRejectRemoteClients, 1, 4096, 4096, 0, in attributes);
+ if (handle.IsInvalid)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ handle.Dispose();
+ throw new AdminOperationException("sandbox.ipc_rejected",
+ $"The local administrative Named Pipe could not be created: {new Win32Exception(error).Message}");
+ }
+ return new NamedPipeServerStream(PipeDirection.InOut, isAsync: true, isConnected: false, handle);
+ }
+ finally { pinned.Free(); }
+ }
+
+ internal static void EnsureSuccess(AdminResponse response)
+ {
+ if (!response.Success) throw new AdminOperationException(response.ErrorCode ?? "sandbox.network_apply_failed",
+ response.Message ?? "The elevated network operation failed.");
+ }
+
+ private static partial class Native
+ {
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct SecurityAttributes
+ {
+ internal int Length;
+ internal nint SecurityDescriptor;
+ internal int InheritHandle;
+ }
+
+ [LibraryImport("kernel32.dll", EntryPoint = "CreateNamedPipeW", SetLastError = true,
+ StringMarshalling = StringMarshalling.Utf16)]
+ internal static partial SafePipeHandle CreateNamedPipe(string name, uint openMode, uint pipeMode,
+ uint maxInstances, uint outBufferSize, uint inBufferSize, uint defaultTimeout,
+ in SecurityAttributes securityAttributes);
+
+ [LibraryImport("advapi32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool GetTokenInformation(nint tokenHandle, int tokenInformationClass,
+ nint tokenInformation, uint tokenInformationLength, out uint returnLength);
+
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool GetNamedPipeClientProcessId(SafePipeHandle pipe, out uint clientProcessId);
+ }
+}
+
+internal sealed class ElevatedNetworkSession(NamedPipeServerStream pipe, Process helper, Guid taskId) : IElevatedNetworkSession
+{
+ private bool _cleaned;
+
+ public async Task<bool> InspectLoopbackAsync(string applicationSid, CancellationToken cancellationToken)
+ {
+ AdminResponse response = await SendAsync(new(AdminProtocol.Version, "inspect-loopback", taskId, applicationSid), cancellationToken).ConfigureAwait(false);
+ return response.LoopbackExempt ?? throw new AdminOperationException("sandbox.ipc_rejected", "The elevated helper omitted the loopback state.");
+ }
+
+ public async Task ApplyAsync(string applicationSid, string networkAccess, IReadOnlyList<AdminNetworkEndpoint> endpoints,
+ bool originallyLoopbackExempt, CancellationToken cancellationToken) =>
+ ElevatedNetworkSessionFactory.EnsureSuccess(await SendAsync(new(AdminProtocol.Version, "apply-network", taskId,
+ applicationSid, networkAccess, endpoints, originallyLoopbackExempt), cancellationToken).ConfigureAwait(false));
+
+ public async Task RecoverLoopbackAsync(string applicationSid, bool originallyLoopbackExempt, CancellationToken cancellationToken) =>
+ ElevatedNetworkSessionFactory.EnsureSuccess(await SendAsync(new(AdminProtocol.Version, "recover-loopback", taskId,
+ applicationSid, OriginallyLoopbackExempt: originallyLoopbackExempt), cancellationToken).ConfigureAwait(false));
+
+ public async Task CleanupAsync(CancellationToken cancellationToken)
+ {
+ if (_cleaned) return;
+ ElevatedNetworkSessionFactory.EnsureSuccess(await SendAsync(new(AdminProtocol.Version, "cleanup-network", taskId), cancellationToken).ConfigureAwait(false));
+ _cleaned = true;
+ }
+
+ private async Task<AdminResponse> SendAsync(AdminRequest request, CancellationToken cancellationToken)
+ {
+ if (helper.HasExited) throw new AdminOperationException("sandbox.elevation_failed", "The elevated helper exited before Task Resource cleanup.");
+ await AdminProtocol.WriteAsync(pipe, request, cancellationToken).ConfigureAwait(false);
+ return await AdminProtocol.ReadAsync<AdminResponse>(pipe, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ if (pipe.IsConnected)
+ {
+ if (!_cleaned) await CleanupAsync(CancellationToken.None).ConfigureAwait(false);
+ await AdminProtocol.WriteAsync(pipe, new AdminRequest(AdminProtocol.Version, "shutdown", taskId), CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+ catch { }
+ pipe.Dispose();
+ try
+ {
+ if (!helper.HasExited) await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+ }
+ catch { if (!helper.HasExited) helper.Kill(entireProcessTree: true); }
+ helper.Dispose();
+ }
+}
diff --git a/src/Agent.Admin/Agent.Admin.Host/Agent.Admin.Host.csproj b/src/Agent.Admin/Agent.Admin.Host/Agent.Admin.Host.csproj
new file mode 100644
index 0000000..d5ee08a
--- /dev/null
+++ b/src/Agent.Admin/Agent.Admin.Host/Agent.Admin.Host.csproj
@@ -0,0 +1,19 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <OutputType>Exe</OutputType>
+ <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
+ <AssemblyName>agent-mcp-admin</AssemblyName>
+ <RootNamespace>Agent.Admin.Host</RootNamespace>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+ <ApplicationManifest>app.manifest</ApplicationManifest>
+ <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
+ <CompilerGeneratedFilesOutputPath>$(IntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
+ </PropertyGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\Agent.Admin.Client\Agent.Admin.Client.csproj" />
+ <PackageReference Include="Microsoft.Windows.CsWin32" PrivateAssets="all" />
+ </ItemGroup>
+ <ItemGroup>
+ <InternalsVisibleTo Include="Agent.Windows.Tests" />
+ </ItemGroup>
+</Project>
diff --git a/src/Agent.Windows/Network/LoopbackExemption.cs b/src/Agent.Admin/Agent.Admin.Host/LoopbackExemption.cs
index a3c68ba..3449491 100644
--- a/src/Agent.Windows/Network/LoopbackExemption.cs
+++ b/src/Agent.Admin/Agent.Admin.Host/LoopbackExemption.cs
@@ -6,7 +6,7 @@ using Windows.Win32.Foundation;
using Windows.Win32.Security;
using Windows.Win32.System.Memory;
-namespace Agent.Windows.Network;
+namespace Agent.Admin.Host;
internal static unsafe class LoopbackExemption
{
diff --git a/src/Agent.Admin/Agent.Admin.Host/NativeMethods.txt b/src/Agent.Admin/Agent.Admin.Host/NativeMethods.txt
new file mode 100644
index 0000000..cb038a6
--- /dev/null
+++ b/src/Agent.Admin/Agent.Admin.Host/NativeMethods.txt
@@ -0,0 +1,27 @@
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineOpen0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineClose0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionBegin0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionCommit0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionAbort0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmSubLayerAdd0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmSubLayerDeleteByKey0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmFilterAdd0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmFilterDeleteById0
+FWPM_SESSION0
+FWPM_SESSION_FLAG_DYNAMIC
+FWPM_SUBLAYER0
+FWPM_FILTER0
+FWPM_FILTER_CONDITION0
+FWPM_LAYER_ALE_AUTH_CONNECT_V4
+FWPM_LAYER_ALE_AUTH_CONNECT_V6
+FWPM_CONDITION_ALE_PACKAGE_ID
+FWPM_CONDITION_IP_REMOTE_ADDRESS
+FWPM_CONDITION_IP_REMOTE_PORT
+FWP_BYTE_ARRAY16
+FWP_ACTION_TYPE
+FWP_MATCH_TYPE
+FWP_DATA_TYPE
+NetworkIsolationGetAppContainerConfig
+NetworkIsolationSetAppContainerConfig
+GetProcessHeap
+HeapFree
diff --git a/src/Agent.Admin/Agent.Admin.Host/Program.cs b/src/Agent.Admin/Agent.Admin.Host/Program.cs
new file mode 100644
index 0000000..635060e
--- /dev/null
+++ b/src/Agent.Admin/Agent.Admin.Host/Program.cs
@@ -0,0 +1,211 @@
+using System.Diagnostics;
+using System.IO.Pipes;
+using System.Net;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+using Agent.Admin.Client;
+using Microsoft.Win32.SafeHandles;
+
+namespace Agent.Admin.Host;
+
+internal static partial class Program
+{
+ private const string FixedProfileName = "AgentSandboxMcp.V1";
+
+ public static async Task<int> Main(string[] args)
+ {
+ try
+ {
+ Arguments options = Arguments.Parse(args);
+ using WindowsIdentity identity = WindowsIdentity.GetCurrent();
+ if (!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator))
+ throw new InvalidOperationException("The elevated helper requires an administrator token.");
+ using Process parent = Process.GetProcessById(checked((int)options.ParentPid));
+ if (parent.HasExited || !string.Equals(Path.GetFileName(parent.MainModule?.FileName), "agent-mcp.exe", StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException("The parent Process is not the fixed agent-mcp executable.");
+
+ using var pipe = new NamedPipeClientStream(".", options.PipeName, PipeDirection.InOut,
+ PipeOptions.Asynchronous | PipeOptions.WriteThrough, TokenImpersonationLevel.Identification);
+ await pipe.ConnectAsync(30_000).ConfigureAwait(false);
+ if (!Native.GetNamedPipeServerProcessId(pipe.SafePipeHandle, out uint serverPid) || serverPid != options.ParentPid)
+ throw new InvalidOperationException("The Named Pipe server Process does not match the expected parent.");
+
+ AdminRequest hello = await AdminProtocol.ReadAsync<AdminRequest>(pipe, CancellationToken.None).ConfigureAwait(false);
+ ValidateEnvelope(hello, options, "hello");
+ await AdminProtocol.WriteAsync(pipe, new AdminResponse(true), CancellationToken.None).ConfigureAwait(false);
+
+ using var state = new NetworkState();
+ while (true)
+ {
+ AdminRequest request = await AdminProtocol.ReadAsync<AdminRequest>(pipe, CancellationToken.None).ConfigureAwait(false);
+ if (request.Version != AdminProtocol.Version || request.TaskId != options.TaskId)
+ {
+ await AdminProtocol.WriteAsync(pipe, new AdminResponse(false, "sandbox.ipc_rejected", "Protocol or Task identity mismatch."), CancellationToken.None).ConfigureAwait(false);
+ return 3;
+ }
+ try
+ {
+ AdminResponse response = request.Operation switch
+ {
+ "inspect-loopback" => Inspect(request),
+ "apply-network" => Apply(request, state),
+ "cleanup-network" => Cleanup(state),
+ "recover-loopback" => Recover(request),
+ "shutdown" => new(true),
+ _ => new(false, "sandbox.ipc_rejected", "Unsupported administrative pipe operation.")
+ };
+ await AdminProtocol.WriteAsync(pipe, response, CancellationToken.None).ConfigureAwait(false);
+ if (request.Operation == "shutdown") return 0;
+ if (!response.Success && response.ErrorCode == "sandbox.ipc_rejected") return 3;
+ }
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ {
+ await AdminProtocol.WriteAsync(pipe, new AdminResponse(false, "sandbox.network_apply_failed", ex.Message), CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+ }
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ {
+ Console.Error.WriteLine($"agent-mcp-admin: {ex.Message}");
+ return 2;
+ }
+ }
+
+ private static AdminResponse Inspect(AdminRequest request)
+ {
+ SecurityIdentifier sid = ValidateSid(request.ApplicationSid);
+ return new(true, LoopbackExempt: LoopbackExemption.Contains(sid));
+ }
+
+ private static AdminResponse Apply(AdminRequest request, NetworkState state)
+ {
+ if (state.Applied) return new(false, "sandbox.ipc_rejected", "Network Task Resource is already applied.");
+ SecurityIdentifier sid = ValidateSid(request.ApplicationSid);
+ IReadOnlyList<NetworkEndpoint> endpoints = ValidateNetwork(request.NetworkAccess, request.Endpoints);
+ bool needsLoopback = endpoints.Any(static item => IPAddress.IsLoopback(item.Address));
+ bool currentLoopback = LoopbackExemption.Contains(sid);
+ if (needsLoopback && currentLoopback != request.OriginallyLoopbackExempt)
+ return new(false, "sandbox.network_apply_failed", "Loopback state changed after Recovery Journal preparation.");
+
+ state.Session = WfpDynamicSession.Open();
+ try
+ {
+ state.Session.ApplyDefaultBlock(sid, endpoints);
+ if (needsLoopback && !currentLoopback) state.LoopbackAdded = LoopbackExemption.Ensure(sid);
+ state.Sid = sid;
+ state.Applied = true;
+ return new(true);
+ }
+ catch
+ {
+ if (state.LoopbackAdded) LoopbackExemption.Remove(sid);
+ state.Session.Dispose();
+ state.Session = null;
+ state.LoopbackAdded = false;
+ throw;
+ }
+ }
+
+ private static AdminResponse Cleanup(NetworkState state)
+ {
+ state.Cleanup();
+ return new(true);
+ }
+
+ private static AdminResponse Recover(AdminRequest request)
+ {
+ SecurityIdentifier sid = ValidateSid(request.ApplicationSid);
+ if (request.OriginallyLoopbackExempt == true) _ = LoopbackExemption.Ensure(sid);
+ else LoopbackExemption.Remove(sid);
+ return new(true);
+ }
+
+ private static SecurityIdentifier ValidateSid(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value)) throw new InvalidDataException("Application SID is required.");
+ var requested = new SecurityIdentifier(value);
+ int result = Native.DeriveAppContainerSidFromAppContainerName(FixedProfileName, out nint pointer);
+ if (result < 0) Marshal.ThrowExceptionForHR(result);
+ try
+ {
+ var fixedSid = new SecurityIdentifier(pointer);
+ if (!fixedSid.Equals(requested)) throw new InvalidDataException("Only the fixed Application Profile SID is accepted.");
+ return requested;
+ }
+ finally { if (pointer != 0) _ = Native.FreeSid(pointer); }
+ }
+
+ private static IReadOnlyList<NetworkEndpoint> ValidateNetwork(string? access, IReadOnlyList<AdminNetworkEndpoint>? input)
+ {
+ if (access is not ("none" or "local" or "restricted")) throw new InvalidDataException("Unsupported Network Access Mode.");
+ if (input is null || input.Count > 4096) throw new InvalidDataException("Network endpoint list is invalid.");
+ var endpoints = input.Select(item =>
+ {
+ if (!IPAddress.TryParse(item.Address, out IPAddress? address) || item.Port is < 1 or > 65535)
+ throw new InvalidDataException("Network endpoint is invalid.");
+ return new NetworkEndpoint(address, (ushort)item.Port);
+ }).Distinct().ToArray();
+ if (access == "none" && endpoints.Length != 0) throw new InvalidDataException("Network none must not contain endpoints.");
+ if (access == "local" && (endpoints.Length == 0 || endpoints.Any(static item => !IPAddress.IsLoopback(item.Address))))
+ throw new InvalidDataException("Network local accepts only IPv4/IPv6 loopback endpoints.");
+ if (access == "restricted" && endpoints.Length == 0) throw new InvalidDataException("Network restricted requires endpoints.");
+ return endpoints;
+ }
+
+ private static void ValidateEnvelope(AdminRequest request, Arguments options, string operation)
+ {
+ if (request.Version != options.Protocol || request.Version != AdminProtocol.Version ||
+ request.TaskId != options.TaskId || request.Operation != operation)
+ throw new InvalidDataException("Administrative pipe handshake mismatch.");
+ }
+
+ private sealed class NetworkState : IDisposable
+ {
+ public WfpDynamicSession? Session { get; set; }
+ public SecurityIdentifier? Sid { get; set; }
+ public bool LoopbackAdded { get; set; }
+ public bool Applied { get; set; }
+ public void Cleanup()
+ {
+ if (LoopbackAdded && Sid is not null) LoopbackExemption.Remove(Sid);
+ LoopbackAdded = false;
+ if (Session is not null)
+ {
+ try { Session.RemovePolicy(); }
+ finally { Session.Dispose(); Session = null; }
+ }
+ Applied = false;
+ }
+ public void Dispose() { try { Cleanup(); } catch { Session?.Dispose(); } }
+ }
+
+ private sealed record Arguments(string PipeName, uint ParentPid, Guid TaskId, int Protocol)
+ {
+ public static Arguments Parse(string[] args)
+ {
+ var values = new Dictionary<string, string>(StringComparer.Ordinal);
+ for (int i = 0; i < args.Length; i += 2)
+ {
+ if (i + 1 >= args.Length || args[i] is not ("--pipe" or "--parent-pid" or "--task" or "--protocol"))
+ throw new ArgumentException("The elevated helper accepts only its fixed internal arguments.");
+ values.Add(args[i], args[i + 1]);
+ }
+ if (values.Count != 4 || !uint.TryParse(values["--parent-pid"], out uint parentPid) ||
+ !Guid.TryParseExact(values["--task"], "N", out Guid taskId) || !int.TryParse(values["--protocol"], out int protocol) ||
+ protocol != AdminProtocol.Version || string.IsNullOrWhiteSpace(values["--pipe"]))
+ throw new ArgumentException("The elevated helper arguments are invalid.");
+ return new(values["--pipe"], parentPid, taskId, protocol);
+ }
+ }
+
+ private static partial class Native
+ {
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out uint serverProcessId);
+ [LibraryImport("userenv.dll", StringMarshalling = StringMarshalling.Utf16)]
+ internal static partial int DeriveAppContainerSidFromAppContainerName(string name, out nint sid);
+ [LibraryImport("advapi32.dll")]
+ internal static partial nint FreeSid(nint sid);
+ }
+}
diff --git a/src/Agent.Windows/Network/WfpDynamicSession.cs b/src/Agent.Admin/Agent.Admin.Host/WfpDynamicSession.cs
index ef8ba8e..4aa21da 100644
--- a/src/Agent.Windows/Network/WfpDynamicSession.cs
+++ b/src/Agent.Admin/Agent.Admin.Host/WfpDynamicSession.cs
@@ -8,29 +8,10 @@ using Windows.Win32.Foundation;
using Windows.Win32.NetworkManagement.WindowsFilteringPlatform;
using Windows.Win32.Security;
-namespace Agent.Windows.Network;
+namespace Agent.Admin.Host;
public sealed record NetworkEndpoint(IPAddress Address, ushort Port);
-public static class NetworkAllowanceSnapshot
-{
- public static async Task<IReadOnlyList<NetworkEndpoint>> ResolveAsync(IEnumerable<(string Host, IReadOnlyList<int> Ports)> allowances, CancellationToken cancellationToken)
- {
- var endpoints = new HashSet<NetworkEndpoint>();
- foreach ((string host, IReadOnlyList<int> ports) in allowances)
- {
- IPAddress[] addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false);
- foreach (int port in ports)
- {
- if (port is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(allowances), $"Invalid network port {port}.");
- foreach (IPAddress address in addresses.Where(static item => item.AddressFamily is System.Net.Sockets.AddressFamily.InterNetwork or System.Net.Sockets.AddressFamily.InterNetworkV6))
- endpoints.Add(new(address, (ushort)port));
- }
- }
- return endpoints.OrderBy(static item => item.Address.ToString(), StringComparer.Ordinal).ThenBy(static item => item.Port).ToArray();
- }
-}
-
public sealed unsafe class WfpDynamicSession : IDisposable
{
private const FWPM_FILTER_FLAGS FwpmFilterFlagClearActionRight = (FWPM_FILTER_FLAGS)0x00000008;
diff --git a/src/Agent.Admin/Agent.Admin.Host/app.manifest b/src/Agent.Admin/Agent.Admin.Host/app.manifest
new file mode 100644
index 0000000..9ee29da
--- /dev/null
+++ b/src/Agent.Admin/Agent.Admin.Host/app.manifest
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
+ <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
+ <security>
+ <requestedPrivileges>
+ <requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
+ </requestedPrivileges>
+ </security>
+ </trustInfo>
+</assembly>
diff --git a/src/Agent.App/Agent.App.csproj b/src/Agent.App/Agent.App.csproj
index e3447c5..c63f3c0 100644
--- a/src/Agent.App/Agent.App.csproj
+++ b/src/Agent.App/Agent.App.csproj
@@ -5,10 +5,12 @@
<AssemblyName>agent-mcp</AssemblyName>
<RootNamespace>Agent.App</RootNamespace>
<Version>1.0.0</Version>
+ <ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Agent.Mcp\Agent.Mcp.csproj" />
<ProjectReference Include="..\Agent.Windows\Agent.Windows.csproj" />
+ <ProjectReference Include="..\Agent.Admin\Agent.Admin.Client\Agent.Admin.Client.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Agent.Core.Tests" />
diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs
index 7372a13..3de5b50 100644
--- a/src/Agent.App/Program.cs
+++ b/src/Agent.App/Program.cs
@@ -12,6 +12,7 @@ using Agent.Core.Tools;
using Agent.Windows.Recovery;
using Agent.Windows.Security;
using Agent.Windows.Tools;
+using Agent.Admin.Client;
namespace Agent.App;
@@ -46,24 +47,27 @@ internal static class Program
UserOnlyDirectory.Ensure(logDirectory);
using var log = new RollingFileLog(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles, configuration.Logging.Level);
log.Information(locale.Get("app.starting"));
- WindowsPlatformCheckResult servePlatform = WindowsPlatformCheck.Run();
+ string helperPath = Path.Combine(AppContext.BaseDirectory, "admin", "agent-mcp-admin.exe");
+ WindowsPlatformCheckResult servePlatform = WindowsPlatformCheck.Run(helperPath);
if (!servePlatform.Success)
{
string failure = locale.Format("app.preflight_failed", new Dictionary<string, object?>
{
["operation"] = servePlatform.ErrorOperation ?? "privilege/BFE/WFP probe",
["error_code"] = servePlatform.ErrorCode,
- ["administrator"] = servePlatform.IsAdministrator.ToString().ToLowerInvariant(),
+ ["platform"] = servePlatform.PlatformSupported.ToString().ToLowerInvariant(),
+ ["profile"] = servePlatform.ApplicationProfileAvailable.ToString().ToLowerInvariant(),
["bfe"] = servePlatform.BfeEngineOpen.ToString().ToLowerInvariant(),
- ["transaction"] = servePlatform.WfpWriteTransaction.ToString().ToLowerInvariant(),
- ["filters"] = servePlatform.WfpFilterAddDelete.ToString().ToLowerInvariant()
+ ["helper"] = servePlatform.HelperAvailable.ToString().ToLowerInvariant(),
+ ["version"] = servePlatform.HelperVersionCompatible.ToString().ToLowerInvariant()
});
log.Fatal(failure);
return 2;
}
var sessionStore = new SessionStore();
- InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, effectiveSandboxPolicy, logDirectory, sessionStore);
+ InternalToolDispatcher toolDispatcher = await CreateToolDispatcherAsync(configuration, effectiveSandboxPolicy, logDirectory,
+ sessionStore, new ElevatedNetworkSessionFactory(helperPath), CancellationToken.None).ConfigureAwait(false);
using var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles);
string taskStateDirectory = Path.Combine(baseDirectory, "state", "mcp-tasks");
UserOnlyDirectory.Ensure(taskStateDirectory);
@@ -163,6 +167,9 @@ internal static class Program
Console.Out.WriteLine("\n" + locale.Get(current ? "apply.no_changes" : "apply.replace_snapshot"));
ConfirmMutation(command.Yes, locale.Get("apply.prompt"));
+ // Application Profile creation is a normal-user, non-elevated configuration operation.
+ // Server startup remains read-only and only derives the fixed SID.
+ _ = AppContainerProfile.EnsureFixed();
UserOnlyDirectory.Ensure(logDirectory);
AppliedConfigurationDocument document = appliedConfiguration.Apply();
var audit = new JsonLinesSecurityAuditSink(Path.Combine(logDirectory, "audit.jsonl"));
@@ -214,14 +221,18 @@ internal static class Program
return previous is null ? "added" : previous == item ? "active" : "changed";
}
- private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, EffectiveSandboxPolicy effectiveSandboxPolicy, string logDirectory, SessionStore? sessionStore = null)
+ private static async Task<InternalToolDispatcher> CreateToolDispatcherAsync(AgentConfiguration configuration,
+ EffectiveSandboxPolicy effectiveSandboxPolicy, string logDirectory, SessionStore? sessionStore,
+ IElevatedNetworkSessionFactory elevatedNetwork, CancellationToken cancellationToken)
{
Directory.CreateDirectory(effectiveSandboxPolicy.JournalDirectory);
Directory.CreateDirectory(effectiveSandboxPolicy.BackupDirectory);
var audit = new JsonLinesSecurityAuditSink(Path.Combine(logDirectory, "audit.jsonl"));
- RecoveryResult recovery = new RecoveryManager(effectiveSandboxPolicy.JournalDirectory, audit).RecoverAll();
+ RecoveryResult recovery = await new RecoveryManager(effectiveSandboxPolicy.JournalDirectory, audit, elevatedNetwork)
+ .RecoverAllAsync(cancellationToken).ConfigureAwait(false);
if (!recovery.IsClean) throw new InvalidOperationException("Recovery did not reach a clean state: " + string.Join(" | ", recovery.Errors));
- var backend = new WindowsToolBackend(effectiveSandboxPolicy, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks, sessionStore));
+ var backend = new WindowsToolBackend(effectiveSandboxPolicy, audit,
+ new TaskGraphToolHandler(configuration.Planner.MaxTasks, sessionStore), elevatedNetwork);
var allowed = InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal);
return new InternalToolDispatcher(new DenyByDefaultPolicy(allowed), backend);
}
diff --git a/src/Agent.App/app.manifest b/src/Agent.App/app.manifest
new file mode 100644
index 0000000..8843587
--- /dev/null
+++ b/src/Agent.App/app.manifest
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
+ <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
+ <security>
+ <requestedPrivileges>
+ <requestedExecutionLevel level="asInvoker" uiAccess="false" />
+ </requestedPrivileges>
+ </security>
+ </trustInfo>
+</assembly>
diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs
index 676fe0d..602460f 100644
--- a/src/Agent.Core/Runtime/AgentRuntime.cs
+++ b/src/Agent.Core/Runtime/AgentRuntime.cs
@@ -299,6 +299,13 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
session.AppendExecutionResult(new ExecutionResult(taskId, call.Function.Name, toolResult.Success, toolResult.Output));
_observer.ToolResult(callContext, call.Function.Name, toolResult);
+ if (!toolResult.Success && !toolResult.RetryAllowed && toolResult.ErrorCategory == "platform" && !isMetadataTool)
+ {
+ string platformMessage = LocalizePlatformFailure(toolResult);
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans,
+ toolResult.ErrorCode ?? "sandbox.elevation_failed", platformMessage);
+ throw new InvalidOperationException(platformMessage);
+ }
if (!toolResult.Success && !toolResult.RetryAllowed && !isMetadataTool)
throw new PolicyDeniedException(toolResult.FailureFingerprint ?? $"policy:{call.Function.Name}:{toolResult.Output}", toolResult.Output,
toolResult.ErrorCode ?? "policy_denied", toolResult.ErrorCategory, toolResult.TargetPath,
@@ -531,6 +538,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
string resolutionKey = error.ErrorCategory switch
{
"filesystem" => "runtime.policy_resolution.filesystem",
+ "acl" => "runtime.policy_resolution.acl",
"process" => "runtime.policy_resolution.process",
"network" => "runtime.policy_resolution.network",
_ => "runtime.policy_resolution.default"
@@ -544,6 +552,20 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
});
}
+ private string LocalizePlatformFailure(ToolExecutionResult result)
+ {
+ if (locale is null) return result.Output;
+ string key = result.ErrorCode switch
+ {
+ "sandbox.elevation_cancelled" => "runtime.platform.elevation_cancelled",
+ "sandbox.helper_incompatible" => "runtime.platform.helper_incompatible",
+ "sandbox.ipc_rejected" => "runtime.platform.ipc_rejected",
+ "sandbox.network_apply_failed" => "runtime.platform.network_apply_failed",
+ _ => "runtime.platform.elevation_failed"
+ };
+ return locale.Format(key, new Dictionary<string, object?> { ["message"] = result.Output });
+ }
+
private bool TryParseSelectedSkills(string content, out IReadOnlyList<AgentSkill> selected)
{
selected = [];
diff --git a/src/Agent.Windows/Agent.Windows.csproj b/src/Agent.Windows/Agent.Windows.csproj
index ff2f299..dc62fce 100644
--- a/src/Agent.Windows/Agent.Windows.csproj
+++ b/src/Agent.Windows/Agent.Windows.csproj
@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Agent.Core\Agent.Core.csproj" />
+ <ProjectReference Include="..\Agent.Admin\Agent.Admin.Client\Agent.Admin.Client.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Windows.CsWin32" PrivateAssets="all" />
diff --git a/src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs b/src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs
index b8ca0ae..89a2106 100644
--- a/src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs
+++ b/src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs
@@ -1,50 +1,56 @@
using System.ComponentModel;
-using System.Security.Principal;
-using Agent.Windows.Network;
+using System.Diagnostics;
+using System.Reflection;
using Agent.Windows.Security;
+using Windows.Win32;
+using Windows.Win32.NetworkManagement.WindowsFilteringPlatform;
namespace Agent.Windows.Diagnostics;
public sealed record WindowsPlatformCheckResult(
- bool IsAdministrator,
+ bool PlatformSupported,
+ bool ApplicationProfileAvailable,
bool BfeEngineOpen,
- bool WfpWriteTransaction,
- bool WfpFilterAddDelete,
+ bool HelperAvailable,
+ bool HelperVersionCompatible,
int? ErrorCode,
string? ErrorOperation)
{
- public bool Success => IsAdministrator && BfeEngineOpen && WfpWriteTransaction && WfpFilterAddDelete;
+ public bool Success => PlatformSupported && ApplicationProfileAvailable && BfeEngineOpen && HelperAvailable && HelperVersionCompatible;
}
public static class WindowsPlatformCheck
{
- public static WindowsPlatformCheckResult Run()
+ public static WindowsPlatformCheckResult Run(string helperPath)
{
- using WindowsIdentity identity = WindowsIdentity.GetCurrent();
- bool administrator = new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
- bool open = false;
- bool transaction = false;
- bool filters = false;
+ bool platform = OperatingSystem.IsWindowsVersionAtLeast(10, 0, 26100);
+ bool profile = false;
+ bool bfe = false;
+ bool helper = File.Exists(helperPath);
+ bool version = false;
try
{
- AppContainerProfile profile = AppContainerProfile.EnsureFixed();
- using WfpDynamicSession session = WfpDynamicSession.Open();
- open = true;
- session.ProbeWriteTransaction();
- transaction = true;
- IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(profile.Sid);
- if (ids.Count != 2 || ids.Any(static id => id == 0)) throw new InvalidOperationException("WFP probe did not create both IPv4 and IPv6 filters.");
- session.RemovePolicy();
- filters = true;
- return new(administrator, open, transaction, filters, null, null);
- }
- catch (Win32Exception ex)
- {
- return new(administrator, open, transaction, filters, ex.NativeErrorCode, ex.Message);
+ _ = AppContainerProfile.DeriveFixed();
+ profile = true;
+ using FwpmEngineClose0SafeHandle engine = OpenBfeReadOnly();
+ bfe = true;
+ if (helper)
+ {
+ string current = Assembly.GetEntryAssembly()?.GetName().Version?.ToString(3) ?? "";
+ string candidate = FileVersionInfo.GetVersionInfo(helperPath).FileVersion ?? "";
+ version = candidate.StartsWith(current, StringComparison.Ordinal);
+ }
+ return new(platform, profile, bfe, helper, version, null, null);
}
+ catch (Win32Exception ex) { return new(platform, profile, bfe, helper, version, ex.NativeErrorCode, ex.Message); }
catch (Exception ex) when (ex is UnauthorizedAccessException or InvalidOperationException or IOException)
- {
- return new(administrator, open, transaction, filters, ex.HResult, ex.Message);
- }
+ { return new(platform, profile, bfe, helper, version, ex.HResult, ex.Message); }
+ }
+
+ private static FwpmEngineClose0SafeHandle OpenBfeReadOnly()
+ {
+ uint result = PInvoke.FwpmEngineOpen0(10, null, null, out FwpmEngineClose0SafeHandle engine);
+ if (result != 0) throw new Win32Exception(unchecked((int)result), $"FwpmEngineOpen0 failed with WFP/Win32 error 0x{result:X8}.");
+ return engine;
}
}
diff --git a/src/Agent.Windows/NativeMethods.txt b/src/Agent.Windows/NativeMethods.txt
index 61efc53..3337948 100644
--- a/src/Agent.Windows/NativeMethods.txt
+++ b/src/Agent.Windows/NativeMethods.txt
@@ -24,30 +24,6 @@ STARTUPINFOEXW
TOKEN_INFORMATION_CLASS
TOKEN_APPCONTAINER_INFORMATION
WaitForSingleObject
+SID_AND_ATTRIBUTES
Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineOpen0
Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineClose0
-Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionBegin0
-Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionCommit0
-Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionAbort0
-Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmSubLayerAdd0
-Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmSubLayerDeleteByKey0
-Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmFilterAdd0
-Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmFilterDeleteById0
-FWPM_SESSION0
-FWPM_SESSION_FLAG_DYNAMIC
-FWPM_SUBLAYER0
-FWPM_FILTER0
-FWPM_FILTER_CONDITION0
-FWPM_LAYER_ALE_AUTH_CONNECT_V4
-FWPM_LAYER_ALE_AUTH_CONNECT_V6
-FWPM_CONDITION_ALE_PACKAGE_ID
-FWPM_CONDITION_IP_REMOTE_ADDRESS
-FWPM_CONDITION_IP_REMOTE_PORT
-FWP_BYTE_ARRAY16
-FWP_ACTION_TYPE
-FWP_MATCH_TYPE
-FWP_DATA_TYPE
-NetworkIsolationGetAppContainerConfig
-NetworkIsolationSetAppContainerConfig
-GetProcessHeap
-HeapFree
diff --git a/src/Agent.Windows/Network/NetworkAllowanceSnapshot.cs b/src/Agent.Windows/Network/NetworkAllowanceSnapshot.cs
new file mode 100644
index 0000000..5feb457
--- /dev/null
+++ b/src/Agent.Windows/Network/NetworkAllowanceSnapshot.cs
@@ -0,0 +1,27 @@
+using System.Net;
+
+namespace Agent.Windows.Network;
+
+public sealed record NetworkEndpoint(IPAddress Address, ushort Port);
+
+public static class NetworkAllowanceSnapshot
+{
+ public static async Task<IReadOnlyList<NetworkEndpoint>> ResolveAsync(IEnumerable<(string Host, IReadOnlyList<int> Ports)> allowances,
+ CancellationToken cancellationToken)
+ {
+ var endpoints = new HashSet<NetworkEndpoint>();
+ foreach ((string host, IReadOnlyList<int> ports) in allowances)
+ {
+ IPAddress[] addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false);
+ foreach (int port in ports)
+ {
+ if (port is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(allowances), $"Invalid network port {port}.");
+ foreach (IPAddress address in addresses.Where(static item => item.AddressFamily is
+ System.Net.Sockets.AddressFamily.InterNetwork or System.Net.Sockets.AddressFamily.InterNetworkV6))
+ endpoints.Add(new(address, (ushort)port));
+ }
+ }
+ return endpoints.OrderBy(static item => item.Address.ToString(), StringComparer.Ordinal)
+ .ThenBy(static item => item.Port).ToArray();
+ }
+}
diff --git a/src/Agent.Windows/Network/TaskNetworkPolicy.cs b/src/Agent.Windows/Network/TaskNetworkPolicy.cs
index 9f78b3d..4bcd8b2 100644
--- a/src/Agent.Windows/Network/TaskNetworkPolicy.cs
+++ b/src/Agent.Windows/Network/TaskNetworkPolicy.cs
@@ -1,79 +1,78 @@
+using System.Net;
using System.Security.Principal;
+using Agent.Admin.Client;
using Agent.Windows.Recovery;
using Agent.Windows.Security;
namespace Agent.Windows.Network;
-public sealed class TaskNetworkPolicy : IDisposable
+public sealed class TaskNetworkPolicy : IAsyncDisposable
{
- private readonly WfpDynamicSession _session;
+ private readonly IElevatedNetworkSession _session;
private readonly TaskResourceJournal _journal;
private readonly ISecurityAuditSink _audit;
- private readonly Guid _entryId;
private readonly SecurityIdentifier _appContainerSid;
private readonly Guid? _loopbackEntryId;
- private readonly bool _loopbackAdded;
private readonly Guid? _sessionId;
private bool _disposed;
- private TaskNetworkPolicy(WfpDynamicSession session, TaskResourceJournal journal, ISecurityAuditSink audit, Guid entryId,
- SecurityIdentifier appContainerSid, Guid? loopbackEntryId, bool loopbackAdded, Guid? sessionId)
+ private TaskNetworkPolicy(IElevatedNetworkSession session, TaskResourceJournal journal, ISecurityAuditSink audit,
+ SecurityIdentifier appContainerSid, Guid? loopbackEntryId, Guid? sessionId)
{
- _session = session; _journal = journal; _audit = audit; _entryId = entryId;
- _appContainerSid = appContainerSid; _loopbackEntryId = loopbackEntryId; _loopbackAdded = loopbackAdded;
+ _session = session;
+ _journal = journal;
+ _audit = audit;
+ _appContainerSid = appContainerSid;
+ _loopbackEntryId = loopbackEntryId;
_sessionId = sessionId;
}
- public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> allowances,
- ISecurityAuditSink audit, Guid? sessionId = null, bool enableLoopback = true)
+ public static async Task<TaskNetworkPolicy> ApplyAsync(IElevatedNetworkSession session, TaskResourceJournal journal,
+ SecurityIdentifier appContainerSid, string networkAccess, IReadOnlyList<NetworkEndpoint> allowances,
+ ISecurityAuditSink audit, Guid? sessionId, CancellationToken cancellationToken)
{
- bool originallyExempt = enableLoopback && LoopbackExemption.Contains(appContainerSid);
- Guid? loopbackEntryId = enableLoopback ? journal.Prepare("network.loopback_exemption", appContainerSid.Value,
+ bool needsLoopback = allowances.Any(static item => IPAddress.IsLoopback(item.Address));
+ bool originallyExempt = needsLoopback && await session.InspectLoopbackAsync(appContainerSid.Value, cancellationToken).ConfigureAwait(false);
+ Guid? loopbackEntryId = needsLoopback ? journal.Prepare("network.loopback_exemption", appContainerSid.Value,
new Dictionary<string, string> { ["originally_present"] = originallyExempt.ToString() }) : null;
- bool loopbackAdded = false;
- Guid entryId = journal.Prepare("wfp.dynamic", appContainerSid.Value, new Dictionary<string, string>
- {
- ["session"] = "dynamic", ["allowances"] = string.Join(',', allowances.Select(static item => $"{item.Address}:{item.Port}"))
- });
- WfpDynamicSession session = WfpDynamicSession.Open();
try
{
- if (enableLoopback)
- {
- loopbackAdded = LoopbackExemption.Ensure(appContainerSid);
- journal.MarkApplied(loopbackEntryId!.Value, new Dictionary<string, string> { ["added"] = loopbackAdded.ToString() });
- }
- IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(appContainerSid, allowances);
- journal.MarkApplied(entryId, new Dictionary<string, string> { ["filter_ids"] = string.Join(',', ids) });
+ AdminNetworkEndpoint[] endpoints = allowances.Select(static item => new AdminNetworkEndpoint(item.Address.ToString(), item.Port)).ToArray();
+ await session.ApplyAsync(appContainerSid.Value, networkAccess, endpoints, originallyExempt, cancellationToken).ConfigureAwait(false);
+ if (loopbackEntryId is not null) journal.MarkApplied(loopbackEntryId.Value);
audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.rule.apply", "applied", new Dictionary<string, string>
{
- ["ipv4_ipv6_default_block"] = "true", ["filter_count"] = ids.Count.ToString(), ["session"] = "dynamic"
+ ["ipv4_ipv6_default_block"] = "true", ["endpoint_count"] = endpoints.Length.ToString(), ["session"] = "dynamic"
}, SessionId: sessionId));
- if (enableLoopback) audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", loopbackAdded ? "added" : "preserved",
- new Dictionary<string, string> { ["app_container_sid"] = appContainerSid.Value }, SessionId: sessionId));
- return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded, sessionId);
+ if (loopbackEntryId is not null)
+ audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", originallyExempt ? "preserved" : "added",
+ new Dictionary<string, string> { ["app_container_sid"] = appContainerSid.Value }, SessionId: sessionId));
+ return new(session, journal, audit, appContainerSid, loopbackEntryId, sessionId);
}
catch
{
- session.Dispose();
- journal.MarkCleaned(entryId);
- if (loopbackAdded) LoopbackExemption.Remove(appContainerSid);
- if (loopbackEntryId is not null) journal.MarkCleaned(loopbackEntryId.Value);
+ bool cleaned = false;
+ try { await session.CleanupAsync(CancellationToken.None).ConfigureAwait(false); cleaned = true; } catch { }
+ if (cleaned && loopbackEntryId is not null) journal.MarkCleaned(loopbackEntryId.Value);
+ try { await session.DisposeAsync().ConfigureAwait(false); } catch { }
throw;
}
}
- public void Dispose()
+ public async ValueTask DisposeAsync()
{
if (_disposed) return;
- _session.RemovePolicy();
- _session.Dispose();
- _journal.MarkCleaned(_entryId);
- if (_loopbackAdded) LoopbackExemption.Remove(_appContainerSid);
- if (_loopbackEntryId is not null) _journal.MarkCleaned(_loopbackEntryId.Value);
- _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.rule.remove", "removed", new Dictionary<string, string> { ["residual_filters"] = "0" }, SessionId: _sessionId));
- if (_loopbackEntryId is not null) _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed",
- new Dictionary<string, string> { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" }, SessionId: _sessionId));
+ try
+ {
+ await _session.CleanupAsync(CancellationToken.None).ConfigureAwait(false);
+ if (_loopbackEntryId is not null) _journal.MarkCleaned(_loopbackEntryId.Value);
+ }
+ finally { await _session.DisposeAsync().ConfigureAwait(false); }
+ _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.rule.remove", "removed",
+ new Dictionary<string, string> { ["residual_filters"] = "0" }, SessionId: _sessionId));
+ if (_loopbackEntryId is not null)
+ _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "restored",
+ new Dictionary<string, string> { ["app_container_sid"] = _appContainerSid.Value }, SessionId: _sessionId));
_disposed = true;
}
}
diff --git a/src/Agent.Windows/Recovery/RecoveryManager.cs b/src/Agent.Windows/Recovery/RecoveryManager.cs
index 9dfeef9..40a7923 100644
--- a/src/Agent.Windows/Recovery/RecoveryManager.cs
+++ b/src/Agent.Windows/Recovery/RecoveryManager.cs
@@ -2,6 +2,7 @@ using System.Security.AccessControl;
using System.Security.Cryptography;
using Agent.Windows.Security;
using Agent.Windows.Network;
+using Agent.Admin.Client;
namespace Agent.Windows.Recovery;
@@ -10,15 +11,39 @@ public sealed record RecoveryResult(int JournalsRecovered, int ResourcesRecovere
public bool IsClean => Errors.Count == 0;
}
-public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink audit)
+public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink audit, IElevatedNetworkSessionFactory? elevatedNetwork = null)
{
- public RecoveryResult RecoverAll()
+ public RecoveryResult RecoverAll() => RecoverAllAsync(CancellationToken.None).GetAwaiter().GetResult();
+
+ public async Task<RecoveryResult> RecoverAllAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(journalDirectory)) return new(0, 0, []);
int journals = 0;
int resources = 0;
var errors = new List<string>();
- foreach (string path in Directory.EnumerateFiles(journalDirectory, "*.journal.json").Order(StringComparer.Ordinal))
+ string[] paths = Directory.EnumerateFiles(journalDirectory, "*.journal.json").Order(StringComparer.Ordinal).ToArray();
+ bool requiresElevation = paths.Select(TaskResourceJournal.Open).SelectMany(static item => item.Entries)
+ .Any(static entry => entry.State != JournalEntryState.Cleaned && entry.Kind == "network.loopback_exemption");
+ IElevatedNetworkSession? elevated = null;
+ if (requiresElevation)
+ {
+ if (elevatedNetwork is null) return new(0, 0, ["Persistent network Recovery requires the elevated helper."]);
+ audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "sandbox.elevation.request", "requested",
+ new Dictionary<string, string> { ["reason"] = "startup_recovery" }));
+ try { elevated = await elevatedNetwork.StartAsync(Guid.Empty, cancellationToken).ConfigureAwait(false); }
+ catch (AdminOperationException ex)
+ {
+ audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty,
+ ex.Code == "sandbox.ipc_rejected" ? "sandbox.ipc.reject" : "sandbox.elevation.deny", "failed",
+ new Dictionary<string, string> { ["reason"] = "startup_recovery", ["message"] = ex.Message }, ErrorCode: ex.Code));
+ throw;
+ }
+ audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "sandbox.elevation.success", "connected",
+ new Dictionary<string, string> { ["reason"] = "startup_recovery" }));
+ }
+ try
+ {
+ foreach (string path in paths)
{
journals++;
try
@@ -28,8 +53,8 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink
foreach (JournalEntry entry in journal.Entries.Reverse())
{
if (entry.State == JournalEntryState.Cleaned) continue;
- if (entry.State == JournalEntryState.Applied) RecoverEntry(journal.TaskId, entry);
- CleanupPrepared(entry);
+ if (entry.State == JournalEntryState.Applied) await RecoverEntryAsync(journal.TaskId, entry, elevated, cancellationToken).ConfigureAwait(false);
+ await CleanupPreparedAsync(entry, elevated, cancellationToken).ConfigureAwait(false);
journal.MarkCleaned(entry.Id);
resources++;
}
@@ -41,10 +66,12 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink
errors.Add($"{path}: {ex.Message}");
}
}
+ }
+ finally { if (elevated is not null) await elevated.DisposeAsync().ConfigureAwait(false); }
return new(journals, resources, errors);
}
- private static void RecoverEntry(Guid taskId, JournalEntry entry)
+ private static async Task RecoverEntryAsync(Guid taskId, JournalEntry entry, IElevatedNetworkSession? elevated, CancellationToken cancellationToken)
{
switch (entry.Kind)
{
@@ -55,14 +82,15 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink
case "dir.rmdir": RecoverRmdir(entry); break;
case "acl": RecoverAcl(entry); break;
case "wfp.dynamic": break; // BFE removes every object when the originating dynamic session ends.
- case "network.loopback_exemption": RecoverLoopback(entry); break;
+ case "network.loopback_exemption": await RecoverLoopbackAsync(entry, elevated, cancellationToken).ConfigureAwait(false); break;
default: throw new InvalidDataException($"Unknown journal resource kind '{entry.Kind}' for task '{taskId}'.");
}
}
- private static void RecoverLoopback(JournalEntry entry)
+ private static async Task RecoverLoopbackAsync(JournalEntry entry, IElevatedNetworkSession? elevated, CancellationToken cancellationToken)
{
- if (!bool.Parse(entry.Data["originally_present"])) LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target));
+ if (elevated is null) throw new InvalidOperationException("Persistent network Recovery requires the elevated helper.");
+ await elevated.RecoverLoopbackAsync(entry.Target, bool.Parse(entry.Data["originally_present"]), cancellationToken).ConfigureAwait(false);
}
private static void RecoverFile(JournalEntry entry)
@@ -124,10 +152,10 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink
Directory.CreateDirectory(entry.Target);
}
- private static void CleanupPrepared(JournalEntry entry)
+ private static async Task CleanupPreparedAsync(JournalEntry entry, IElevatedNetworkSession? elevated, CancellationToken cancellationToken)
{
- if (entry.Kind == "network.loopback_exemption" && !bool.Parse(entry.Data["originally_present"]))
- LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target));
+ if (entry.Kind == "network.loopback_exemption" && entry.State != JournalEntryState.Applied)
+ await RecoverLoopbackAsync(entry, elevated, cancellationToken).ConfigureAwait(false);
if (entry.Data.TryGetValue("temporary_path", out string? temporary) && temporary.Length > 0) File.Delete(temporary);
if (entry.Data.TryGetValue("backup_path", out string? backup) && backup.Length > 0)
{
diff --git a/src/Agent.Windows/Security/AppContainerProfile.cs b/src/Agent.Windows/Security/AppContainerProfile.cs
index 47aa64e..97258ec 100644
--- a/src/Agent.Windows/Security/AppContainerProfile.cs
+++ b/src/Agent.Windows/Security/AppContainerProfile.cs
@@ -27,6 +27,14 @@ public sealed partial class AppContainerProfile
}
}
+ public static AppContainerProfile DeriveFixed()
+ {
+ int result = Native.DeriveAppContainerSidFromAppContainerName(FixedName, out nint sidPointer);
+ if (result < 0) Marshal.ThrowExceptionForHR(result);
+ try { return new(FixedName, new SecurityIdentifier(sidPointer)); }
+ finally { if (sidPointer != 0) _ = Native.FreeSid(sidPointer); }
+ }
+
private static partial class Native
{
[LibraryImport("userenv.dll", StringMarshalling = StringMarshalling.Utf16)]
diff --git a/src/Agent.Windows/Security/TaskAclManager.cs b/src/Agent.Windows/Security/TaskAclManager.cs
index be0080d..6534e5a 100644
--- a/src/Agent.Windows/Security/TaskAclManager.cs
+++ b/src/Agent.Windows/Security/TaskAclManager.cs
@@ -1,13 +1,34 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using Agent.Windows.Recovery;
+using Microsoft.Win32.SafeHandles;
namespace Agent.Windows.Security;
public sealed record AclSnapshot(string Path, byte[] SecurityDescriptor, Guid? JournalEntryId = null);
-public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurityAuditSink audit, TaskResourceJournal? journal = null)
+public sealed partial class TaskAclManager(SecurityIdentifier appContainerSid, ISecurityAuditSink audit, TaskResourceJournal? journal = null)
{
+ private const uint WriteDac = 0x00040000;
+ private const uint ShareReadWriteDelete = 0x00000007;
+ private const uint OpenExisting = 3;
+ private const uint BackupSemantics = 0x02000000;
+
+ public static void DemandWriteDac(string path)
+ {
+ string canonical = ResolvedPath.Resolve(path);
+ uint flags = Directory.Exists(canonical) ? BackupSemantics : 0;
+ using SafeFileHandle handle = CreateFile(canonical, WriteDac, ShareReadWriteDelete, 0, OpenExisting, flags, 0);
+ if (!handle.IsInvalid) return;
+ int error = Marshal.GetLastPInvokeError();
+ throw new SandboxPolicyException("sandbox.access_denied",
+ $"The current user cannot open '{canonical}' with WRITE_DAC ({new Win32Exception(error).Message}).",
+ "acl", canonical, "WRITE_DAC",
+ "Use a root owned by the current user or grant that user WRITE_DAC; elevation does not bypass this requirement.");
+ }
+
public AclSnapshot Grant(Guid taskId, string path, FileCapability capabilities)
{
string canonical = ResolvedPath.Resolve(path);
@@ -112,4 +133,8 @@ public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurity
if ((capabilities & FileCapability.Delete) != 0) rights |= FileSystemRights.Delete | FileSystemRights.DeleteSubdirectoriesAndFiles;
return rights;
}
+
+ [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
+ private static partial SafeFileHandle CreateFile(string fileName, uint desiredAccess, uint shareMode,
+ nint securityAttributes, uint creationDisposition, uint flagsAndAttributes, nint templateFile);
}
diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs
index ec122e9..388b145 100644
--- a/src/Agent.Windows/Tools/WindowsToolBackend.cs
+++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs
@@ -4,6 +4,7 @@ using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Agent.Core.Tools;
+using Agent.Admin.Client;
using Agent.Windows.Network;
using Agent.Windows.Recovery;
using Agent.Windows.Security;
@@ -28,56 +29,31 @@ public sealed record EffectiveSandboxPolicy(
string ResourceProfile = "normal",
IReadOnlyList<string>? BaselineCapabilities = null);
-public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurityAuditSink audit, ITaskUpdateHandler? taskUpdates = null) : IInternalToolBackend
+public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurityAuditSink audit, ITaskUpdateHandler? taskUpdates = null,
+ IElevatedNetworkSessionFactory? elevatedNetwork = null) : IInternalToolBackend
{
private readonly ConcurrentDictionary<Guid, TaskContext> _contexts = new();
private readonly PathPolicy _paths = new(settings.Files);
private readonly EntryExecutablePolicy _entryExecutables = new(settings.EntryExecutables, settings.RuntimeRoots, settings.ArtifactRoots);
private readonly ITaskUpdateHandler _taskUpdates = taskUpdates ?? new TaskGraphToolHandler();
+ private readonly IElevatedNetworkSessionFactory _elevatedNetwork = elevatedNetwork ??
+ new ElevatedNetworkSessionFactory(Path.Combine(AppContext.BaseDirectory, "admin", "agent-mcp-admin.exe"));
+ private bool _cleanupFailed;
public async Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken)
{
if (settings.BaselineCapabilities is not null &&
!settings.BaselineCapabilities.SequenceEqual(["registryRead", "lpacInstrumentation"], StringComparer.Ordinal))
throw new InvalidOperationException("Effective Sandbox Policy baseline capabilities do not match the fixed LPAC baseline.");
+ if (_cleanupFailed) throw new InvalidOperationException("A prior Task Resource cleanup failed; restart is required for Recovery.");
if (_contexts.Count != 0) throw new InvalidOperationException("Only one Task Resource scope may be active.");
AppContainerProfile profile = AppContainerProfile.EnsureFixed();
TaskResourceJournal journal = TaskResourceJournal.Create(settings.JournalDirectory, taskId);
var acl = new TaskAclManager(profile.Sid, audit, journal);
- var context = new TaskContext(journal, acl, sessionId);
+ var context = new TaskContext(journal, acl, profile, sessionId);
_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,
- group.Aggregate(FileCapability.None, static (value, item) => value | item.Capabilities),
- group.All(static item => item.Origin == "os_runtime_root"))))
- {
- // 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;
- // 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
- // prohibitively expensive for runtime trees such as Python installations.
- context.Acls.Add(acl.Grant(taskId, path, capabilities));
- }
- IReadOnlyList<NetworkEndpoint> allowances = settings.NetworkAccess != "none"
- ? await NetworkAllowanceSnapshot.ResolveAsync(settings.NetworkAllowances, cancellationToken).ConfigureAwait(false)
- : [];
- context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit, sessionId,
- enableLoopback: settings.NetworkAccess != "none");
if (!_contexts.TryAdd(taskId, context)) throw new InvalidOperationException("Task context already exists.");
audit.Write(new(DateTimeOffset.UtcNow, taskId, "task.start", "running", new Dictionary<string, string>(), SessionId: sessionId));
}
@@ -89,7 +65,7 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit
}
}
- public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken)
+ public async Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken)
{
TaskContext context = Get(taskId);
audit.Write(new(DateTimeOffset.UtcNow, taskId, "tool.call", "attempted",
@@ -98,12 +74,12 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit
{
return toolName switch
{
- "file_search" => Task.FromResult(Search(arguments)),
- "file_read" => Task.FromResult(Read(arguments)),
- "file_edit" => Task.FromResult(Edit(context, arguments)),
- "process_run" => Task.FromResult(RunProcess(context, arguments, cancellationToken)),
- "task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)),
- "dir" => Task.FromResult(DirectoryOp(context, arguments)),
+ "file_search" => Search(arguments),
+ "file_read" => Read(arguments),
+ "file_edit" => Edit(context, arguments),
+ "process_run" => await RunProcessAsync(taskId, context, arguments, cancellationToken).ConfigureAwait(false),
+ "task_update" => _taskUpdates.Execute(taskId, arguments),
+ "dir" => DirectoryOp(context, arguments),
_ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.")
};
}
@@ -112,16 +88,23 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit
audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied",
new Dictionary<string, string> { ["tool"] = toolName, ["message"] = ex.Message },
SessionId: context.SessionId, ErrorCode: ex.Code));
- return Task.FromResult(new ToolExecutionResult(false, ex.Message, false, $"{toolName}:{ex.Code}:{ex.Message}", RetryAllowed: false,
+ return new ToolExecutionResult(false, ex.Message, false, $"{toolName}:{ex.Code}:{ex.Message}", RetryAllowed: false,
ErrorCode: ex.Code, ErrorCategory: ex.Category, TargetPath: ex.TargetPath,
- RequiredPermission: ex.RequiredPermission, Resolution: ex.Resolution));
+ RequiredPermission: ex.RequiredPermission, Resolution: ex.Resolution);
+ }
+ catch (AdminOperationException ex)
+ {
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, ex.Code == "sandbox.ipc_rejected" ? "sandbox.ipc.reject" : "sandbox.elevation.deny",
+ "failed", new Dictionary<string, string> { ["message"] = ex.Message }, SessionId: context.SessionId, ErrorCode: ex.Code));
+ return new ToolExecutionResult(false, ex.Message, false, $"{toolName}:{ex.Code}", RetryAllowed: false,
+ ErrorCode: ex.Code, ErrorCategory: "platform", Resolution: "Approve the Windows elevation prompt and retry the Agent Task.");
}
catch (Exception ex) when (ex is ToolValidationException or JsonException or InvalidOperationException)
{
- return Task.FromResult(new ToolExecutionResult(false, ex.Message, false,
+ return new ToolExecutionResult(false, ex.Message, false,
$"{toolName}:tool.input_invalid:{ex.Message}", RetryAllowed: true,
ErrorCode: "tool.input_invalid", ErrorCategory: "tool",
- Resolution: "Correct the Tool arguments using the published schema and retry."));
+ Resolution: "Correct the Tool arguments using the published schema and retry.");
}
}
@@ -273,18 +256,23 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit
throw new ToolValidationException($"Unsupported dir operation '{operation}'.");
}
- private ToolExecutionResult RunProcess(TaskContext context, JsonElement arguments, CancellationToken cancellationToken)
+ private async Task<ToolExecutionResult> RunProcessAsync(Guid taskId, TaskContext context, JsonElement arguments, CancellationToken cancellationToken)
{
- Guid taskId = context.Journal.TaskId;
string executable = arguments.GetProperty("executable").GetString()!;
string cwd = arguments.GetProperty("cwd").GetString()!;
_paths.Demand(cwd, FileCapability.Read);
string[] args = arguments.GetProperty("args").EnumerateArray().Select(static item => item.GetString()!).ToArray();
+ _ = _entryExecutables.VerifyEntry(executable, cwd);
string? managedEntryPoint = _entryExecutables.ManagedEntryPoint(executable, args, cwd);
- if (managedEntryPoint is not null) _paths.Demand(managedEntryPoint, FileCapability.Execute);
+ if (managedEntryPoint is not null)
+ {
+ _paths.Demand(managedEntryPoint, FileCapability.Execute);
+ _entryExecutables.VerifyManagedEntryPoint(managedEntryPoint);
+ }
+ await EnsureProcessResourcesAsync(taskId, context, cancellationToken).ConfigureAwait(false);
int seconds = ResolveProcessTimeout(arguments, settings.ProcessTimeoutSeconds);
string? stdin = arguments.TryGetProperty("stdin", out JsonElement stdinElement) && stdinElement.ValueKind == JsonValueKind.String ? stdinElement.GetString() : null;
- RestrictedProcessResult result = new RestrictedProcessLauncher(_entryExecutables, audit, AppContainerProfile.EnsureFixed().Sid, settings.JobLimits,
+ RestrictedProcessResult result = new RestrictedProcessLauncher(_entryExecutables, audit, context.Profile.Sid, settings.JobLimits,
allowNetwork: settings.NetworkAccess != "none")
.Run(taskId, executable, args, cwd, TimeSpan.FromSeconds(seconds), stdin, context.SessionId, cancellationToken);
string output = $"Process exited with code {result.ExitCode} (PID {result.ProcessId}).\nstdout:\n{result.StandardOutput}\nstderr:\n{result.StandardError}";
@@ -292,6 +280,66 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit
result.ExitCode == 0 ? null : $"process:{executable}:{result.ExitCode}");
}
+ private async Task EnsureProcessResourcesAsync(Guid taskId, TaskContext context, CancellationToken cancellationToken)
+ {
+ if (context.ProcessResourcesApplied) return;
+ await context.ResourceGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (context.ProcessResourcesApplied) return;
+ IReadOnlyList<NetworkEndpoint> allowances = settings.NetworkAccess != "none"
+ ? await NetworkAllowanceSnapshot.ResolveAsync(settings.NetworkAllowances, cancellationToken).ConfigureAwait(false)
+ : [];
+ IReadOnlyList<(string Path, FileCapability Capabilities)> aclTargets = ProcessAclTargets();
+ foreach ((string path, _) in aclTargets) TaskAclManager.DemandWriteDac(path);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "sandbox.elevation.request", "requested",
+ new Dictionary<string, string> { ["reason"] = "network_task_resource" }, SessionId: context.SessionId));
+ IElevatedNetworkSession? elevated = null;
+ try
+ {
+ elevated = await _elevatedNetwork.StartAsync(taskId, cancellationToken).ConfigureAwait(false);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "sandbox.elevation.success", "connected",
+ new Dictionary<string, string> { ["protocol"] = AdminProtocol.Version.ToString() }, SessionId: context.SessionId));
+ ApplyProcessAcls(taskId, context, aclTargets);
+ context.Network = await TaskNetworkPolicy.ApplyAsync(elevated, context.Journal, context.Profile.Sid,
+ settings.NetworkAccess, allowances, audit, context.SessionId, cancellationToken).ConfigureAwait(false);
+ elevated = null; // ownership transferred to TaskNetworkPolicy
+ context.ProcessResourcesApplied = true;
+ }
+ finally
+ {
+ if (elevated is not null) await elevated.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+ finally { context.ResourceGate.Release(); }
+ }
+
+ private IReadOnlyList<(string Path, FileCapability Capabilities)> ProcessAclTargets()
+ {
+ var targets = new List<(string Path, FileCapability Capabilities)>();
+ 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,
+ group.Aggregate(FileCapability.None, static (value, item) => value | item.Capabilities),
+ group.All(static item => item.Origin == "os_runtime_root"))))
+ {
+ if (osRuntimeRoot) continue;
+ // Entry files are identity gates, not ACL roots. Runtime/Workspace directory ACLs
+ // provide inherited LPAC access where configured; OS/Program Files entries retain
+ // their existing protected ACLs and are validated by the real LPAC launch test.
+ if (File.Exists(path)) continue;
+ targets.Add((path, capabilities));
+ }
+ return targets;
+ }
+
+ private static void ApplyProcessAcls(Guid taskId, TaskContext context,
+ IReadOnlyList<(string Path, FileCapability Capabilities)> targets)
+ {
+ foreach ((string path, FileCapability capabilities) in targets)
+ context.Acls.Add(context.Acl.Grant(taskId, path, capabilities));
+ }
+
internal static int ResolveProcessTimeout(JsonElement arguments, int configuredTimeoutSeconds)
{
int seconds = arguments.TryGetProperty("timeout_seconds", out JsonElement timeout) ? timeout.GetInt32() : configuredTimeoutSeconds;
@@ -304,17 +352,18 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit
private async Task CleanupAsync(Guid taskId, TaskContext context)
{
List<Exception> failures = [];
- try { context.Network?.Dispose(); } catch (Exception ex) { failures.Add(ex); }
+ if (context.Network is not null)
+ try { await context.Network.DisposeAsync().ConfigureAwait(false); } catch (Exception ex) { failures.Add(ex); }
foreach (AclSnapshot snapshot in context.Acls.AsEnumerable().Reverse()) try { context.Acl.Restore(taskId, snapshot); } catch (Exception ex) { failures.Add(ex); }
if (failures.Count == 0) context.Journal.CompleteAndDelete();
- await Task.CompletedTask;
- if (failures.Count > 0) throw new AggregateException("Task resource cleanup failed.", failures);
+ if (failures.Count > 0)
+ {
+ _cleanupFailed = true;
+ throw new AggregateException("Task resource cleanup failed.", failures);
+ }
}
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)
{
@@ -346,12 +395,15 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit
}
}
- private sealed class TaskContext(TaskResourceJournal journal, TaskAclManager acl, Guid sessionId)
+ private sealed class TaskContext(TaskResourceJournal journal, TaskAclManager acl, AppContainerProfile profile, Guid sessionId)
{
public TaskResourceJournal Journal { get; } = journal;
public Guid SessionId { get; } = sessionId;
+ public AppContainerProfile Profile { get; } = profile;
public TaskAclManager Acl { get; } = acl;
public List<AclSnapshot> Acls { get; } = [];
public TaskNetworkPolicy? Network { get; set; }
+ public SemaphoreSlim ResourceGate { get; } = new(1, 1);
+ public bool ProcessResourcesApplied { get; set; }
}
}
diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
index b7f27e7..04a9714 100644
--- a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
+++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
@@ -205,6 +205,27 @@ public sealed class PromptAndRuntimeTests
}
[Fact]
+ public async Task Elevation_failure_is_terminal_for_the_task_without_policy_replanning()
+ {
+ TaskRegistry registry = new();
+ var backend = new PlatformFailingBackend();
+ var dispatcher = new InternalToolDispatcher(
+ new DenyByDefaultPolicy(new HashSet<string>(["process_run"], StringComparer.Ordinal)), backend);
+ var runtime = new AgentRuntime(registry, new FailingThenFinalOllamaClient(),
+ new AgentRuntimeSettings("", "en", [], "sandbox", MaxIterations: 10, SameFailureLimit: 2), dispatcher);
+ TaskSnapshot task = registry.Enqueue("run build", null, null);
+ registry.Transition(task.TaskId, AgentTaskState.Planning);
+
+ await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken);
+
+ Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed));
+ Assert.Equal(AgentTaskState.Failed, failed!.State);
+ Assert.Equal(0, failed.Replans);
+ Assert.Equal("sandbox.elevation_cancelled", failed.LastErrorCode);
+ Assert.Equal(1, backend.CallCount);
+ }
+
+ [Fact]
public async Task Runtime_does_not_retry_a_sandbox_policy_denial()
{
TaskRegistry registry = new();
@@ -577,6 +598,20 @@ public sealed class PromptAndRuntimeTests
public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => Task.CompletedTask;
}
+ private sealed class PlatformFailingBackend : IInternalToolBackend
+ {
+ public int CallCount { get; private set; }
+ public Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) => Task.CompletedTask;
+ public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken)
+ {
+ CallCount++;
+ return Task.FromResult(new ToolExecutionResult(false, "The Windows elevation request was cancelled.", false,
+ "process_run:sandbox.elevation_cancelled", RetryAllowed: false,
+ ErrorCode: "sandbox.elevation_cancelled", ErrorCategory: "platform"));
+ }
+ public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
private sealed class FailingThenFinalOllamaClient : IOllamaClient
{
private int _calls;
diff --git a/tests/Agent.Windows.Tests/AdministrativeBoundaryTests.cs b/tests/Agent.Windows.Tests/AdministrativeBoundaryTests.cs
new file mode 100644
index 0000000..b218072
--- /dev/null
+++ b/tests/Agent.Windows.Tests/AdministrativeBoundaryTests.cs
@@ -0,0 +1,87 @@
+using System.Buffers.Binary;
+using System.Text;
+using System.Text.Json;
+using Agent.Admin.Client;
+
+namespace Agent.Windows.Tests;
+
+public sealed class AdministrativeBoundaryTests
+{
+ [Fact]
+ public async Task Administrative_protocol_round_trips_only_the_fixed_network_request_shape()
+ {
+ var request = new AdminRequest(AdminProtocol.Version, "apply-network", Guid.NewGuid(), "S-1-15-2-1", "local",
+ [new("127.0.0.1", 11434)], false);
+ using var stream = new MemoryStream();
+ await AdminProtocol.WriteAsync(stream, request, TestContext.Current.CancellationToken);
+ stream.Position = 0;
+ AdminRequest actual = await AdminProtocol.ReadAsync<AdminRequest>(stream, TestContext.Current.CancellationToken);
+ Assert.Equal(request.Version, actual.Version);
+ Assert.Equal(request.Operation, actual.Operation);
+ Assert.Equal(request.TaskId, actual.TaskId);
+ Assert.Equal(request.ApplicationSid, actual.ApplicationSid);
+ Assert.Equal(request.NetworkAccess, actual.NetworkAccess);
+ Assert.Equal(request.OriginallyLoopbackExempt, actual.OriginallyLoopbackExempt);
+ Assert.Equal(request.Endpoints, actual.Endpoints);
+ string json = Encoding.UTF8.GetString(stream.ToArray());
+ Assert.DoesNotContain("executable", json, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("commandLine", json, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("securityDescriptor", json, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("layer", json, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("action", json, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Administrative_protocol_rejects_oversized_messages_before_allocation()
+ {
+ byte[] header = new byte[4];
+ BinaryPrimitives.WriteInt32LittleEndian(header, AdminProtocol.MaxMessageBytes + 1);
+ using var stream = new MemoryStream(header);
+ await Assert.ThrowsAsync<InvalidDataException>(() => AdminProtocol.ReadAsync<AdminRequest>(stream,
+ TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task Administrative_protocol_rejects_fields_outside_the_fixed_schema()
+ {
+ byte[] payload = Encoding.UTF8.GetBytes("""{"version":1,"operation":"hello","taskId":"00000000-0000-0000-0000-000000000000","command":"cmd.exe"}""");
+ byte[] framed = new byte[payload.Length + 4];
+ BinaryPrimitives.WriteInt32LittleEndian(framed, payload.Length);
+ payload.CopyTo(framed.AsSpan(4));
+ using var stream = new MemoryStream(framed);
+
+ await Assert.ThrowsAsync<JsonException>(() => AdminProtocol.ReadAsync<AdminRequest>(stream,
+ TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public void Manifests_and_native_call_ownership_match_the_process_boundary()
+ {
+ string root = RepositoryRoot();
+ string mainManifest = File.ReadAllText(Path.Combine(root, "src", "Agent.App", "app.manifest"));
+ string helperManifest = File.ReadAllText(Path.Combine(root, "src", "Agent.Admin", "Agent.Admin.Host", "app.manifest"));
+ Assert.Contains("level=\"asInvoker\"", mainManifest, StringComparison.Ordinal);
+ Assert.Contains("level=\"requireAdministrator\"", helperManifest, StringComparison.Ordinal);
+ Assert.Contains("uiAccess=\"false\"", helperManifest, StringComparison.Ordinal);
+ string normalNative = File.ReadAllText(Path.Combine(root, "src", "Agent.Windows", "NativeMethods.txt"));
+ Assert.DoesNotContain("FwpmTransactionBegin0", normalNative, StringComparison.Ordinal);
+ Assert.DoesNotContain("FwpmFilterAdd0", normalNative, StringComparison.Ordinal);
+ Assert.DoesNotContain("NetworkIsolationSetAppContainerConfig", normalNative, StringComparison.Ordinal);
+ string helperNative = File.ReadAllText(Path.Combine(root, "src", "Agent.Admin", "Agent.Admin.Host", "NativeMethods.txt"));
+ Assert.Contains("FwpmTransactionBegin0", helperNative, StringComparison.Ordinal);
+ Assert.Contains("NetworkIsolationSetAppContainerConfig", helperNative, StringComparison.Ordinal);
+ string client = File.ReadAllText(Path.Combine(root, "src", "Agent.Admin", "Agent.Admin.Client", "ElevatedNetworkSession.cs"));
+ Assert.Contains("PipeRejectRemoteClients", client, StringComparison.Ordinal);
+ }
+
+ private static string RepositoryRoot()
+ {
+ DirectoryInfo? current = new(AppContext.BaseDirectory);
+ while (current is not null)
+ {
+ if (File.Exists(Path.Combine(current.FullName, "AgentSandboxMcp.slnx"))) return current.FullName;
+ current = current.Parent;
+ }
+ throw new DirectoryNotFoundException("Repository root was not found.");
+ }
+}
diff --git a/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj b/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj
index 7888f53..ec4f87f 100644
--- a/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj
+++ b/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj
@@ -6,6 +6,7 @@
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
</PropertyGroup>
<ItemGroup>
+ <ProjectReference Include="..\..\src\Agent.Admin\Agent.Admin.Host\Agent.Admin.Host.csproj" />
<ProjectReference Include="..\..\src\Agent.Windows\Agent.Windows.csproj" />
<ProjectReference Include="..\..\src\Agent.App\Agent.App.csproj" />
</ItemGroup>
diff --git a/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs
index 6686833..d3224f4 100644
--- a/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs
+++ b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs
@@ -80,7 +80,9 @@ internal static class CrashScenarioBootstrap
edit.Rollback();
break;
case "wfp":
- _ = TaskNetworkPolicy.Apply(journal, AppContainerProfile.EnsureFixed().Sid, [], new MemorySecurityAuditSink());
+ _ = TaskNetworkPolicy.ApplyAsync(new DirectElevatedNetworkSession(), journal,
+ AppContainerProfile.EnsureFixed().Sid, "none", [], new MemorySecurityAuditSink(), null,
+ CancellationToken.None).GetAwaiter().GetResult();
break;
default:
throw new InvalidOperationException($"Unknown crash scenario '{scenario}'.");
diff --git a/tests/Agent.Windows.Tests/DirectElevatedNetworkSession.cs b/tests/Agent.Windows.Tests/DirectElevatedNetworkSession.cs
new file mode 100644
index 0000000..b194ed1
--- /dev/null
+++ b/tests/Agent.Windows.Tests/DirectElevatedNetworkSession.cs
@@ -0,0 +1,75 @@
+using System.Net;
+using System.Security.Principal;
+using Agent.Admin.Client;
+using Agent.Admin.Host;
+
+namespace Agent.Windows.Tests;
+
+internal sealed class DirectElevatedNetworkSession : IElevatedNetworkSession
+{
+ private WfpDynamicSession? _wfp;
+ private SecurityIdentifier? _sid;
+ private bool _loopbackAdded;
+
+ public Task<bool> InspectLoopbackAsync(string applicationSid, CancellationToken cancellationToken) =>
+ Task.FromResult(LoopbackExemption.Contains(new SecurityIdentifier(applicationSid)));
+
+ public Task ApplyAsync(string applicationSid, string networkAccess, IReadOnlyList<AdminNetworkEndpoint> endpoints,
+ bool originallyLoopbackExempt, CancellationToken cancellationToken)
+ {
+ _sid = new(applicationSid);
+ NetworkEndpoint[] values = endpoints.Select(static item => new NetworkEndpoint(IPAddress.Parse(item.Address), (ushort)item.Port)).ToArray();
+ _wfp = WfpDynamicSession.Open();
+ _wfp.ApplyDefaultBlock(_sid, values);
+ if (values.Any(static item => IPAddress.IsLoopback(item.Address)) && !originallyLoopbackExempt)
+ _loopbackAdded = LoopbackExemption.Ensure(_sid);
+ return Task.CompletedTask;
+ }
+
+ public Task RecoverLoopbackAsync(string applicationSid, bool originallyLoopbackExempt, CancellationToken cancellationToken)
+ {
+ var sid = new SecurityIdentifier(applicationSid);
+ if (originallyLoopbackExempt) _ = LoopbackExemption.Ensure(sid); else LoopbackExemption.Remove(sid);
+ return Task.CompletedTask;
+ }
+
+ public Task CleanupAsync(CancellationToken cancellationToken)
+ {
+ if (_loopbackAdded && _sid is not null) LoopbackExemption.Remove(_sid);
+ _loopbackAdded = false;
+ if (_wfp is not null) { _wfp.RemovePolicy(); _wfp.Dispose(); _wfp = null; }
+ return Task.CompletedTask;
+ }
+
+ public async ValueTask DisposeAsync() => await CleanupAsync(CancellationToken.None).ConfigureAwait(false);
+}
+
+internal sealed class DirectElevatedNetworkSessionFactory : IElevatedNetworkSessionFactory
+{
+ public int Starts { get; private set; }
+ public Task<IElevatedNetworkSession> StartAsync(Guid taskId, CancellationToken cancellationToken)
+ {
+ Starts++;
+ return Task.FromResult<IElevatedNetworkSession>(new DirectElevatedNetworkSession());
+ }
+}
+
+internal sealed class NoOpElevatedNetworkSessionFactory : IElevatedNetworkSessionFactory
+{
+ public int Starts { get; private set; }
+ public Task<IElevatedNetworkSession> StartAsync(Guid taskId, CancellationToken cancellationToken)
+ {
+ Starts++;
+ return Task.FromResult<IElevatedNetworkSession>(new NoOpElevatedNetworkSession());
+ }
+
+ private sealed class NoOpElevatedNetworkSession : IElevatedNetworkSession
+ {
+ public Task<bool> InspectLoopbackAsync(string applicationSid, CancellationToken cancellationToken) => Task.FromResult(false);
+ public Task ApplyAsync(string applicationSid, string networkAccess, IReadOnlyList<AdminNetworkEndpoint> endpoints,
+ bool originallyLoopbackExempt, CancellationToken cancellationToken) => Task.CompletedTask;
+ public Task RecoverLoopbackAsync(string applicationSid, bool originallyLoopbackExempt, CancellationToken cancellationToken) => Task.CompletedTask;
+ public Task CleanupAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+}
diff --git a/tests/Agent.Windows.Tests/ReadmeOperationalTests.cs b/tests/Agent.Windows.Tests/ReadmeOperationalTests.cs
index fd70f06..5240feb 100644
--- a/tests/Agent.Windows.Tests/ReadmeOperationalTests.cs
+++ b/tests/Agent.Windows.Tests/ReadmeOperationalTests.cs
@@ -381,11 +381,12 @@ public sealed class ReadmeOperationalTests
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));
+ return new(journal, TaskNetworkPolicy.ApplyAsync(new DirectElevatedNetworkSession(), journal, scope.Profile.Sid,
+ endpoints.Count == 0 ? "none" : "local", endpoints, scope.Audit, null, CancellationToken.None).GetAwaiter().GetResult());
}
public void Dispose()
{
- _policy.Dispose();
+ _policy.DisposeAsync().AsTask().GetAwaiter().GetResult();
_journal.CompleteAndDelete();
string? root = Path.GetDirectoryName(_journal.Path);
if (root is not null && Directory.Exists(root)) Directory.Delete(root, true);
diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
index 6dbc9ab..5b10417 100644
--- a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
+++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
@@ -11,6 +11,8 @@ using Agent.Core.Tools;
using System.Text.Json;
using Agent.App.Sandbox;
using Agent.App.Configuration;
+using LoopbackExemption = Agent.Admin.Host.LoopbackExemption;
+using WfpDynamicSession = Agent.Admin.Host.WfpDynamicSession;
namespace Agent.Windows.Tests;
@@ -793,7 +795,7 @@ public sealed class SecurityPolicyTests
try
{
- RecoveryResult recovered = new RecoveryManager(journals, audit).RecoverAll();
+ RecoveryResult recovered = new RecoveryManager(journals, audit, new DirectElevatedNetworkSessionFactory()).RecoverAll();
Assert.True(recovered.IsClean);
Assert.Equal(1, recovered.JournalsRecovered);
@@ -957,7 +959,8 @@ public sealed class SecurityPolicyTests
session.ProbeWriteTransaction();
IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(profile.Sid,
- [new NetworkEndpoint(IPAddress.Loopback, 11434), new NetworkEndpoint(IPAddress.IPv6Loopback, 11434)]);
+ [new Agent.Admin.Host.NetworkEndpoint(IPAddress.Loopback, 11434),
+ new Agent.Admin.Host.NetworkEndpoint(IPAddress.IPv6Loopback, 11434)]);
Assert.Equal(4, ids.Count);
Assert.All(ids, id => Assert.NotEqual(0UL, id));
@@ -965,7 +968,7 @@ public sealed class SecurityPolicyTests
}
[Fact]
- public async Task Windows_backend_applies_file_effect_and_cleans_acl_wfp_temp_and_journal()
+ public async Task Windows_backend_file_only_effect_does_not_apply_acl_or_network_resources()
{
using TestDirectory directory = new();
string workspace = Path.Combine(directory.Path, "workspace");
@@ -978,7 +981,8 @@ public sealed class SecurityPolicyTests
[new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [],
new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
- var backend = new WindowsToolBackend(settings, audit);
+ var elevated = new DirectElevatedNetworkSessionFactory();
+ var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: elevated);
Guid taskId = Guid.NewGuid();
Guid sessionId = Guid.NewGuid();
await backend.BeginTaskAsync(taskId, sessionId, TestContext.Current.CancellationToken);
@@ -995,13 +999,13 @@ public sealed class SecurityPolicyTests
Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals")));
string backups = Path.Combine(directory.Path, "backups");
Assert.True(!Directory.Exists(backups) || !Directory.EnumerateFileSystemEntries(backups).Any());
- Assert.Contains(audit.Events, item => item.Event == "network.rule.remove" && item.Data["residual_filters"] == "0");
+ Assert.Equal(0, elevated.Starts);
+ Assert.DoesNotContain(audit.Events, item => item.Event is "network.rule.apply" or "network.rule.remove");
Assert.Contains(audit.Events, item => item.Event == "task.start" && item.SessionId == sessionId);
Assert.Contains(audit.Events, item => item.Event == "tool.call" && item.SessionId == sessionId);
Assert.Contains(audit.Events, item => item.Event == "file.write" && item.SessionId == sessionId);
Assert.Contains(audit.Events, item => item.Event == "task.end" && item.SessionId == sessionId);
- Assert.Single(audit.Events, item => item.Event == "acl.apply");
- Assert.Single(audit.Events, item => item.Event == "acl.restore");
+ Assert.DoesNotContain(audit.Events, item => item.Event is "acl.apply" or "acl.restore");
}
[Fact]
@@ -1023,7 +1027,7 @@ public sealed class SecurityPolicyTests
[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);
+ var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory());
Guid taskId = Guid.NewGuid();
await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
@@ -1042,6 +1046,34 @@ public sealed class SecurityPolicyTests
}
[Fact]
+ public async Task File_only_task_does_not_start_the_elevated_helper()
+ {
+ using TestDirectory directory = new();
+ string workspace = Path.Combine(directory.Path, "workspace");
+ Directory.CreateDirectory(workspace);
+ string input = Path.Combine(workspace, "input.txt");
+ File.WriteAllText(input, "read-only operation");
+ var factory = new NoOpElevatedNetworkSessionFactory();
+ var settings = new EffectiveSandboxPolicy(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute, "workspace")],
+ [], [], [workspace], 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, new MemorySecurityAuditSink(), elevatedNetwork: factory);
+ Guid taskId = Guid.NewGuid();
+
+ await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
+ using JsonDocument read = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(input)}},"mode":"whole"}""");
+ ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_read", read.RootElement,
+ TestContext.Current.CancellationToken);
+ await backend.EndTaskAsync(taskId, result.Success, TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.Equal(0, factory.Starts);
+ }
+
+ [Fact]
public async Task Dir_mkdir_creates_directory_within_approved_root_and_cleans_up()
{
using TestDirectory directory = new();
@@ -1054,7 +1086,7 @@ public sealed class SecurityPolicyTests
[new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [],
new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
- var backend = new WindowsToolBackend(settings, audit);
+ var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory());
Guid taskId = Guid.NewGuid();
await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument mkdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"mkdir"}""");
@@ -1085,7 +1117,7 @@ public sealed class SecurityPolicyTests
[new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [],
new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
- var backend = new WindowsToolBackend(settings, audit);
+ var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory());
Guid taskId = Guid.NewGuid();
await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument rmdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"rmdir"}""");
@@ -1113,7 +1145,7 @@ public sealed class SecurityPolicyTests
[new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [],
new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
- var backend = new WindowsToolBackend(settings, audit);
+ var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory());
Guid taskId = Guid.NewGuid();
await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument rmdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"rmdir"}""");
@@ -1140,7 +1172,7 @@ public sealed class SecurityPolicyTests
[new FilePolicyEntry(workspace, FileCapability.Read)], [], [], [],
new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
- var backend = new WindowsToolBackend(settings, audit);
+ var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory());
Guid taskId = Guid.NewGuid();
await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument list = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(workspace)}},"operation":"list"}""");
@@ -1166,7 +1198,7 @@ public sealed class SecurityPolicyTests
[new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], [], [],
new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
- var backend = new WindowsToolBackend(settings, audit);
+ var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory());
Guid taskId = Guid.NewGuid();
await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
string badTarget = Path.Combine(outside.Path, "escaped");
@@ -1192,7 +1224,7 @@ public sealed class SecurityPolicyTests
[new FilePolicyEntry(workspace, FileCapability.Read)], [], [], [],
new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
- var backend = new WindowsToolBackend(settings, audit);
+ var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory());
Guid taskId = Guid.NewGuid();
await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument tail = JsonDocument.Parse($$"""{"root":{{JsonSerializer.Serialize(binaryFile)}},"mode":"tail","query":"","tail_lines":10}""");
@@ -1231,7 +1263,7 @@ public sealed class SecurityPolicyTests
[new FilePolicyEntry(workspace, FileCapability.Read)], [], [], [],
new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
- var backend = new WindowsToolBackend(settings, audit);
+ var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory());
Guid taskId = Guid.NewGuid();
await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument tail = JsonDocument.Parse($$"""{"root":{{JsonSerializer.Serialize(textFile)}},"mode":"tail","query":"","tail_lines":10}""");
@@ -1256,7 +1288,7 @@ public sealed class SecurityPolicyTests
[new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [],
new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
- var backend = new WindowsToolBackend(settings, audit);
+ var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory());
Guid taskId = Guid.NewGuid();
await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}""");
@@ -1287,7 +1319,7 @@ public sealed class SecurityPolicyTests
[new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], [], [],
new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
- var backend = new WindowsToolBackend(settings, audit);
+ var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory());
Guid taskId = Guid.NewGuid();
await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}""");
@@ -1317,7 +1349,7 @@ public sealed class SecurityPolicyTests
[new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], [], [],
new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
- var backend = new WindowsToolBackend(settings, audit);
+ var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory());
Guid taskId = Guid.NewGuid();
await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}""");
@@ -1358,7 +1390,8 @@ public sealed class SecurityPolicyTests
{
Guid taskId = Guid.NewGuid();
TaskResourceJournal journal = TaskResourceJournal.Create(Path.Combine(directory.Path, "deny-journal"), taskId);
- using (TaskNetworkPolicy.Apply(journal, profile.Sid, [], audit))
+ await using (await TaskNetworkPolicy.ApplyAsync(new DirectElevatedNetworkSession(), journal, profile.Sid,
+ "none", [], audit, null, TestContext.Current.CancellationToken))
{
RestrictedProcessResult denied = Curl(launcher, taskId, curl, deniedServer.Url, Environment.SystemDirectory);
Assert.NotEqual(0u, denied.ExitCode);
@@ -1383,7 +1416,8 @@ public sealed class SecurityPolicyTests
[
new(allowedAddress, checked((ushort)allowedV4.Port))
];
- using (TaskNetworkPolicy.Apply(allowJournal, profile.Sid, allowances, audit))
+ await using (await TaskNetworkPolicy.ApplyAsync(new DirectElevatedNetworkSession(), allowJournal, profile.Sid,
+ "local", allowances, audit, null, TestContext.Current.CancellationToken))
{
RestrictedProcessResult v4 = Curl(launcher, allowTaskId, curl, allowedV4.Url, Environment.SystemDirectory);
RestrictedProcessResult wrongPort = Curl(launcher, allowTaskId, curl, deniedPort.Url, Environment.SystemDirectory);