From 9337ac3e5edd42bb17f5e2ade6c2de02c7ef576d Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Thu, 13 Aug 2026 22:48:24 +0900 Subject: feat!: isolate privileged sandbox network operations per task Run agent-mcp with an explicit asInvoker manifest and move WFP and loopback mutations into a fixed requireAdministrator helper under the release admin directory. Add the Agent.Admin client and host projects with task-scoped runas launch, a logon-SID and Administrators-only local named pipe, remote-client rejection, mutual PID validation, strict versioned JSON framing, fixed network-only operations, and bounded messages. Delay ACL and network Task Resource application until the first process_run. Verify executable identity, managed entry points, DNS snapshots, and normal-token WRITE_DAC access before elevation; reuse the helper only within the Task and keep elevation failures terminal without policy replanning. Keep LPAC creation, executable approval, Job confinement, and ACL leases in the normal process. Make startup platform checks read-only, create the fixed Application Profile during apply, preserve dynamic WFP lifetime in the helper, and route persistent loopback recovery through the same elevated boundary. Update recovery journaling, localized elevation and IPC errors, audit events, release publishing, manifests, README guidance, Specifications, Design, implementation records, and requirement traceability for the on-demand elevation model. Add administrative-boundary, file-only no-elevation, elevation-terminal, lazy-resource, crash-recovery, manifest, IPC schema, and operational verification coverage. Verified Release build with zero warnings, Core 105/105, Planner 10/10, administrative boundary 4/4, elevated Windows suite 79/79, and published stdio/HTTP MCP E2E. --- AgentSandboxMcp.slnx | 2 + README.md | 19 +- config/locales/en.json | 10 +- config/locales/ja.json | 10 +- doc/AuditReport.md | 4 + doc/Design.md | 34 ++- doc/Specifications.md | 21 +- plans/ImplementationPlan.md | 2 + plans/RequirementTraceability.md | 15 ++ scripts/Publish-Release.ps1 | 8 +- scripts/Run-Final-ElevatedVerification.ps1 | 31 ++- scripts/Verify-PublishedHttp.ps1 | 3 - scripts/Verify-Readme.ps1 | 2 - .../Agent.Admin.Client/AdminProtocol.cs | 61 ++++++ .../Agent.Admin.Client/Agent.Admin.Client.csproj | 7 + .../Agent.Admin.Client/ElevatedNetworkSession.cs | 237 +++++++++++++++++++++ .../Agent.Admin.Host/Agent.Admin.Host.csproj | 19 ++ .../Agent.Admin.Host/LoopbackExemption.cs | 123 +++++++++++ src/Agent.Admin/Agent.Admin.Host/NativeMethods.txt | 27 +++ src/Agent.Admin/Agent.Admin.Host/Program.cs | 211 ++++++++++++++++++ .../Agent.Admin.Host/WfpDynamicSession.cs | 189 ++++++++++++++++ src/Agent.Admin/Agent.Admin.Host/app.manifest | 10 + src/Agent.App/Agent.App.csproj | 2 + src/Agent.App/Program.cs | 27 ++- src/Agent.App/app.manifest | 10 + src/Agent.Core/Runtime/AgentRuntime.cs | 22 ++ src/Agent.Windows/Agent.Windows.csproj | 1 + .../Diagnostics/WindowsPlatformCheck.cs | 64 +++--- src/Agent.Windows/NativeMethods.txt | 26 +-- src/Agent.Windows/Network/LoopbackExemption.cs | 123 ----------- .../Network/NetworkAllowanceSnapshot.cs | 27 +++ src/Agent.Windows/Network/TaskNetworkPolicy.cs | 83 ++++---- src/Agent.Windows/Network/WfpDynamicSession.cs | 208 ------------------ src/Agent.Windows/Recovery/RecoveryManager.cs | 52 +++-- src/Agent.Windows/Security/AppContainerProfile.cs | 8 + src/Agent.Windows/Security/TaskAclManager.cs | 27 ++- src/Agent.Windows/Tools/WindowsToolBackend.cs | 160 +++++++++----- tests/Agent.Core.Tests/PromptAndRuntimeTests.cs | 35 +++ .../AdministrativeBoundaryTests.cs | 87 ++++++++ .../Agent.Windows.Tests/Agent.Windows.Tests.csproj | 1 + .../Agent.Windows.Tests/CrashScenarioBootstrap.cs | 4 +- .../DirectElevatedNetworkSession.cs | 75 +++++++ .../Agent.Windows.Tests/ReadmeOperationalTests.cs | 5 +- tests/Agent.Windows.Tests/SecurityPolicyTests.cs | 74 +++++-- 44 files changed, 1606 insertions(+), 560 deletions(-) create mode 100644 src/Agent.Admin/Agent.Admin.Client/AdminProtocol.cs create mode 100644 src/Agent.Admin/Agent.Admin.Client/Agent.Admin.Client.csproj create mode 100644 src/Agent.Admin/Agent.Admin.Client/ElevatedNetworkSession.cs create mode 100644 src/Agent.Admin/Agent.Admin.Host/Agent.Admin.Host.csproj create mode 100644 src/Agent.Admin/Agent.Admin.Host/LoopbackExemption.cs create mode 100644 src/Agent.Admin/Agent.Admin.Host/NativeMethods.txt create mode 100644 src/Agent.Admin/Agent.Admin.Host/Program.cs create mode 100644 src/Agent.Admin/Agent.Admin.Host/WfpDynamicSession.cs create mode 100644 src/Agent.Admin/Agent.Admin.Host/app.manifest create mode 100644 src/Agent.App/app.manifest delete mode 100644 src/Agent.Windows/Network/LoopbackExemption.cs create mode 100644 src/Agent.Windows/Network/NetworkAllowanceSnapshot.cs delete mode 100644 src/Agent.Windows/Network/WfpDynamicSession.cs create mode 100644 tests/Agent.Windows.Tests/AdministrativeBoundaryTests.cs create mode 100644 tests/Agent.Windows.Tests/DirectElevatedNetworkSession.cs diff --git a/AgentSandboxMcp.slnx b/AgentSandboxMcp.slnx index 36c52dc..33c59f0 100644 --- a/AgentSandboxMcp.slnx +++ b/AgentSandboxMcp.slnx @@ -1,5 +1,7 @@ + + 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 []` | 任意の設定path | Task中の一時Sandbox Resourceのみ | 設定を検証し、管理者権限・BFE・WFPをpreflightしてServer起動 | +| `agent-mcp []` | 任意の設定path | Task中の一時Sandbox Resourceのみ | read-onlyのPlatform/BFE/helper検査後に通常権限でServer起動 | | `agent-mcp show []` | 任意の設定path | なし | 現在の希望状態、Resolved Path、hash、適用一致状態を表示 | | `agent-mcp apply [] [--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: ### 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? 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(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 ReadAsync(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(payload, Json) ?? throw new InvalidDataException("Administrative pipe message is empty."); + } + + private static async Task ReadExactlyAsync(Stream stream, Memory 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 @@ + + + net10.0-windows10.0.26100.0 + Agent.Admin.Client + true + + 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 InspectLoopbackAsync(string applicationSid, CancellationToken cancellationToken); + Task ApplyAsync(string applicationSid, string networkAccess, IReadOnlyList endpoints, + bool originallyLoopbackExempt, CancellationToken cancellationToken); + Task RecoverLoopbackAsync(string applicationSid, bool originallyLoopbackExempt, CancellationToken cancellationToken); + Task CleanupAsync(CancellationToken cancellationToken); +} + +public interface IElevatedNetworkSessionFactory +{ + Task 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 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(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(), + 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 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 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 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(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 @@ + + + Exe + net10.0-windows10.0.26100.0 + agent-mcp-admin + Agent.Admin.Host + true + app.manifest + true + $(IntermediateOutputPath)Generated + + + + + + + + + diff --git a/src/Agent.Admin/Agent.Admin.Host/LoopbackExemption.cs b/src/Agent.Admin/Agent.Admin.Host/LoopbackExemption.cs new file mode 100644 index 0000000..3449491 --- /dev/null +++ b/src/Agent.Admin/Agent.Admin.Host/LoopbackExemption.cs @@ -0,0 +1,123 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Security; +using Windows.Win32.System.Memory; + +namespace Agent.Admin.Host; + +internal static unsafe class LoopbackExemption +{ + private const string MutexName = "Local\\AgentSandboxMcpLoopbackExemption"; + + public static bool Contains(SecurityIdentifier sid) + { + using MutexLease mutex = AcquireMutex(); + return Read().Any(item => item.Sid.Equals(sid)); + } + + public static bool Ensure(SecurityIdentifier sid) + { + using MutexLease mutex = AcquireMutex(); + List entries = Read(); + if (entries.Any(item => item.Sid.Equals(sid))) return false; + entries.Add(new(sid, 0)); + Write(entries); + return true; + } + + public static void Remove(SecurityIdentifier sid) + { + using MutexLease mutex = AcquireMutex(); + List entries = Read(); + if (entries.RemoveAll(item => item.Sid.Equals(sid)) == 0) return; + Write(entries); + } + + private static MutexLease AcquireMutex() + { + var mutex = new Mutex(false, MutexName); + try + { + try + { + if (!mutex.WaitOne(TimeSpan.FromSeconds(30))) throw new TimeoutException("Timed out waiting for the loopback exemption lock."); + } + catch (AbandonedMutexException) + { + // Ownership is granted to this thread when the prior owner died. + } + return new(mutex); + } + catch + { + mutex.Dispose(); + throw; + } + } + + private static List Read() + { + uint result = PInvoke.NetworkIsolationGetAppContainerConfig(out uint count, out SID_AND_ATTRIBUTES* native); + ThrowIfError(result, "NetworkIsolationGetAppContainerConfig"); + try + { + var entries = new List(checked((int)count)); + for (uint index = 0; index < count; index++) + { + SID_AND_ATTRIBUTES item = native[index]; + entries.Add(new(new SecurityIdentifier((nint)item.Sid.Value), item.Attributes)); + } + return entries; + } + finally + { + HANDLE heap = PInvoke.GetProcessHeap(); + for (uint index = 0; index < count; index++) + if (native[index].Sid.Value is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native[index].Sid.Value); + if (native is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native); + } + } + + private static void Write(IReadOnlyList entries) + { + var native = new SID_AND_ATTRIBUTES[entries.Count]; + var allocations = new List(entries.Count); + try + { + for (int index = 0; index < entries.Count; index++) + { + byte[] bytes = new byte[entries[index].Sid.BinaryLength]; + entries[index].Sid.GetBinaryForm(bytes, 0); + void* allocation = NativeMemory.Alloc((nuint)bytes.Length); + Marshal.Copy(bytes, 0, (nint)allocation, bytes.Length); + allocations.Add((nint)allocation); + native[index].Sid = new PSID(allocation); + native[index].Attributes = entries[index].Attributes; + } + ThrowIfError(PInvoke.NetworkIsolationSetAppContainerConfig(native), "NetworkIsolationSetAppContainerConfig"); + } + finally + { + foreach (nint allocation in allocations) NativeMemory.Free((void*)allocation); + } + } + + private static void ThrowIfError(uint result, string operation) + { + if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with Win32 error 0x{result:X8}."); + } + + private sealed record Entry(SecurityIdentifier Sid, uint Attributes); + + private sealed class MutexLease(Mutex mutex) : IDisposable + { + public void Dispose() + { + mutex.ReleaseMutex(); + mutex.Dispose(); + } + } +} 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 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(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(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 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 ValidateNetwork(string? access, IReadOnlyList? 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(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.Admin/Agent.Admin.Host/WfpDynamicSession.cs b/src/Agent.Admin/Agent.Admin.Host/WfpDynamicSession.cs new file mode 100644 index 0000000..4aa21da --- /dev/null +++ b/src/Agent.Admin/Agent.Admin.Host/WfpDynamicSession.cs @@ -0,0 +1,189 @@ +using System.ComponentModel; +using System.Buffers.Binary; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.NetworkManagement.WindowsFilteringPlatform; +using Windows.Win32.Security; + +namespace Agent.Admin.Host; + +public sealed record NetworkEndpoint(IPAddress Address, ushort Port); + +public sealed unsafe class WfpDynamicSession : IDisposable +{ + private const FWPM_FILTER_FLAGS FwpmFilterFlagClearActionRight = (FWPM_FILTER_FLAGS)0x00000008; + private readonly FwpmEngineClose0SafeHandle _engine; + private Guid? _subLayerKey; + private readonly List _filterIds = []; + + private WfpDynamicSession(FwpmEngineClose0SafeHandle engine) => _engine = engine; + public static WfpDynamicSession Open(string displayName = "Agent Sandbox MCP dynamic task session") + { + FWPM_SESSION0 session = default; + session.flags = PInvoke.FWPM_SESSION_FLAG_DYNAMIC; + uint result = PInvoke.FwpmEngineOpen0(10, null, session, out FwpmEngineClose0SafeHandle engine); + ThrowIfError(result, "FwpmEngineOpen0"); + return new(engine); + } + + public void ProbeWriteTransaction() + { + uint result = PInvoke.FwpmTransactionBegin0(_engine, 0); + ThrowIfError(result, "FwpmTransactionBegin0"); + result = PInvoke.FwpmTransactionAbort0(_engine); + ThrowIfError(result, "FwpmTransactionAbort0"); + } + + public IReadOnlyList ApplyDefaultBlock(SecurityIdentifier appContainerSid, IReadOnlyList? allowedEndpoints = null) + { + if (_subLayerKey is not null) throw new InvalidOperationException("WFP policy is already applied in this session."); + byte[] sidBytes = new byte[appContainerSid.BinaryLength]; + appContainerSid.GetBinaryForm(sidBytes, 0); + uint result = PInvoke.FwpmTransactionBegin0(_engine, 0); + ThrowIfError(result, "FwpmTransactionBegin0"); + bool committed = false; + try + { + Guid subLayerKey = Guid.NewGuid(); + fixed (char* subLayerName = "Agent Sandbox MCP task sublayer") + { + FWPM_SUBLAYER0 subLayer = default; + subLayer.subLayerKey = subLayerKey; + subLayer.displayData.name = new PWSTR(subLayerName); + subLayer.weight = 0x7FFF; + ThrowIfError(PInvoke.FwpmSubLayerAdd0(_engine, in subLayer, default), "FwpmSubLayerAdd0"); + } + + fixed (byte* sidPointer = sidBytes) + { + foreach (NetworkEndpoint endpoint in allowedEndpoints ?? []) _filterIds.Add(AddPackagePermit(endpoint, subLayerKey, (SID*)sidPointer)); + _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv4 default block")); + _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv6 default block")); + } + ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0"); + committed = true; + _subLayerKey = subLayerKey; + return _filterIds.ToArray(); + } + finally + { + if (!committed) + { + _filterIds.Clear(); + _ = PInvoke.FwpmTransactionAbort0(_engine); + } + } + } + + public void RemovePolicy() + { + if (_subLayerKey is null) return; + ThrowIfError(PInvoke.FwpmTransactionBegin0(_engine, 0), "FwpmTransactionBegin0(remove)"); + bool committed = false; + try + { + foreach (ulong filterId in _filterIds.AsEnumerable().Reverse()) ThrowIfError(PInvoke.FwpmFilterDeleteById0(_engine, filterId), "FwpmFilterDeleteById0"); + Guid subLayerKey = _subLayerKey.Value; + ThrowIfError(PInvoke.FwpmSubLayerDeleteByKey0(_engine, in subLayerKey), "FwpmSubLayerDeleteByKey0"); + ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0(remove)"); + committed = true; + _filterIds.Clear(); + _subLayerKey = null; + } + finally + { + if (!committed) _ = PInvoke.FwpmTransactionAbort0(_engine); + } + } + + public void Dispose() + { + _engine.Dispose(); + } + + private ulong AddPackageBlock(Guid layerKey, Guid subLayerKey, SID* sid, string displayName) + { + FWPM_FILTER_CONDITION0 condition = default; + condition.fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID; + condition.matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + condition.conditionValue.type = FWP_DATA_TYPE.FWP_SID; + condition.conditionValue.sid = sid; + fixed (char* name = displayName) + { + FWPM_FILTER0 filter = default; + filter.filterKey = Guid.NewGuid(); + filter.displayData.name = new PWSTR(name); + filter.layerKey = layerKey; + filter.subLayerKey = subLayerKey; + filter.weight.type = FWP_DATA_TYPE.FWP_UINT8; + filter.weight.uint8 = 8; + filter.numFilterConditions = 1; + filter.filterCondition = &condition; + filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_BLOCK; + ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0"); + return id; + } + } + + private ulong AddPackagePermit(NetworkEndpoint endpoint, Guid subLayerKey, SID* sid) + { + FWPM_FILTER_CONDITION0* conditions = stackalloc FWPM_FILTER_CONDITION0[3]; + conditions[0].fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID; + conditions[0].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + conditions[0].conditionValue.type = FWP_DATA_TYPE.FWP_SID; + conditions[0].conditionValue.sid = sid; + conditions[1].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS; + conditions[1].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + Guid layer; + FWP_BYTE_ARRAY16 ipv6 = default; + if (endpoint.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4; + conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_UINT32; + // FWP_UINT32 uses host-order numeric values. For 127.0.0.1 the + // numeric value is 0x7F000001; parse the network-order octets into + // that value rather than reinterpreting their in-memory byte order. + conditions[1].conditionValue.uint32 = BinaryPrimitives.ReadUInt32BigEndian(endpoint.Address.GetAddressBytes()); + } + else + { + layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6; + ipv6.byteArray16 = endpoint.Address.GetAddressBytes(); + conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_BYTE_ARRAY16_TYPE; + conditions[1].conditionValue.byteArray16 = &ipv6; + } + conditions[2].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT; + conditions[2].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + conditions[2].conditionValue.type = FWP_DATA_TYPE.FWP_UINT16; + conditions[2].conditionValue.uint16 = endpoint.Port; + string displayName = $"Agent Sandbox MCP allow {endpoint.Address}:{endpoint.Port}"; + fixed (char* name = displayName) + { + FWPM_FILTER0 filter = default; + filter.filterKey = Guid.NewGuid(); + filter.displayData.name = new PWSTR(name); + filter.layerKey = layer; + filter.subLayerKey = subLayerKey; + filter.weight.type = FWP_DATA_TYPE.FWP_UINT8; + filter.weight.uint8 = 15; + filter.numFilterConditions = 3; + filter.filterCondition = conditions; + // A plain permit is a soft permit and can be overridden by a block + // from another sub-layer (for example Windows Firewall). The exact + // task allowance must be a hard permit; our higher-weight match in + // this sub-layer still prevents the task default-block from running. + filter.flags = FwpmFilterFlagClearActionRight; + filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_PERMIT; + ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0(permit)"); + return id; + } + } + + private static void ThrowIfError(uint result, string operation) + { + if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with WFP/Win32 error 0x{result:X8}."); + } +} 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 @@ + + + + + + + + + + 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 @@ agent-mcp Agent.App 1.0.0 + app.manifest + 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 { ["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 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 @@ + + + + + + + + + + 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 { ["message"] = result.Output }); + } + private bool TryParseSelectedSkills(string content, out IReadOnlyList 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 @@ + 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 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/LoopbackExemption.cs b/src/Agent.Windows/Network/LoopbackExemption.cs deleted file mode 100644 index a3c68ba..0000000 --- a/src/Agent.Windows/Network/LoopbackExemption.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.ComponentModel; -using System.Runtime.InteropServices; -using System.Security.Principal; -using Windows.Win32; -using Windows.Win32.Foundation; -using Windows.Win32.Security; -using Windows.Win32.System.Memory; - -namespace Agent.Windows.Network; - -internal static unsafe class LoopbackExemption -{ - private const string MutexName = "Local\\AgentSandboxMcpLoopbackExemption"; - - public static bool Contains(SecurityIdentifier sid) - { - using MutexLease mutex = AcquireMutex(); - return Read().Any(item => item.Sid.Equals(sid)); - } - - public static bool Ensure(SecurityIdentifier sid) - { - using MutexLease mutex = AcquireMutex(); - List entries = Read(); - if (entries.Any(item => item.Sid.Equals(sid))) return false; - entries.Add(new(sid, 0)); - Write(entries); - return true; - } - - public static void Remove(SecurityIdentifier sid) - { - using MutexLease mutex = AcquireMutex(); - List entries = Read(); - if (entries.RemoveAll(item => item.Sid.Equals(sid)) == 0) return; - Write(entries); - } - - private static MutexLease AcquireMutex() - { - var mutex = new Mutex(false, MutexName); - try - { - try - { - if (!mutex.WaitOne(TimeSpan.FromSeconds(30))) throw new TimeoutException("Timed out waiting for the loopback exemption lock."); - } - catch (AbandonedMutexException) - { - // Ownership is granted to this thread when the prior owner died. - } - return new(mutex); - } - catch - { - mutex.Dispose(); - throw; - } - } - - private static List Read() - { - uint result = PInvoke.NetworkIsolationGetAppContainerConfig(out uint count, out SID_AND_ATTRIBUTES* native); - ThrowIfError(result, "NetworkIsolationGetAppContainerConfig"); - try - { - var entries = new List(checked((int)count)); - for (uint index = 0; index < count; index++) - { - SID_AND_ATTRIBUTES item = native[index]; - entries.Add(new(new SecurityIdentifier((nint)item.Sid.Value), item.Attributes)); - } - return entries; - } - finally - { - HANDLE heap = PInvoke.GetProcessHeap(); - for (uint index = 0; index < count; index++) - if (native[index].Sid.Value is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native[index].Sid.Value); - if (native is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native); - } - } - - private static void Write(IReadOnlyList entries) - { - var native = new SID_AND_ATTRIBUTES[entries.Count]; - var allocations = new List(entries.Count); - try - { - for (int index = 0; index < entries.Count; index++) - { - byte[] bytes = new byte[entries[index].Sid.BinaryLength]; - entries[index].Sid.GetBinaryForm(bytes, 0); - void* allocation = NativeMemory.Alloc((nuint)bytes.Length); - Marshal.Copy(bytes, 0, (nint)allocation, bytes.Length); - allocations.Add((nint)allocation); - native[index].Sid = new PSID(allocation); - native[index].Attributes = entries[index].Attributes; - } - ThrowIfError(PInvoke.NetworkIsolationSetAppContainerConfig(native), "NetworkIsolationSetAppContainerConfig"); - } - finally - { - foreach (nint allocation in allocations) NativeMemory.Free((void*)allocation); - } - } - - private static void ThrowIfError(uint result, string operation) - { - if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with Win32 error 0x{result:X8}."); - } - - private sealed record Entry(SecurityIdentifier Sid, uint Attributes); - - private sealed class MutexLease(Mutex mutex) : IDisposable - { - public void Dispose() - { - mutex.ReleaseMutex(); - mutex.Dispose(); - } - } -} 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> ResolveAsync(IEnumerable<(string Host, IReadOnlyList Ports)> allowances, + CancellationToken cancellationToken) + { + var endpoints = new HashSet(); + foreach ((string host, IReadOnlyList 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 allowances, - ISecurityAuditSink audit, Guid? sessionId = null, bool enableLoopback = true) + public static async Task ApplyAsync(IElevatedNetworkSession session, TaskResourceJournal journal, + SecurityIdentifier appContainerSid, string networkAccess, IReadOnlyList 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 { ["originally_present"] = originallyExempt.ToString() }) : null; - bool loopbackAdded = false; - Guid entryId = journal.Prepare("wfp.dynamic", appContainerSid.Value, new Dictionary - { - ["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 { ["added"] = loopbackAdded.ToString() }); - } - IReadOnlyList ids = session.ApplyDefaultBlock(appContainerSid, allowances); - journal.MarkApplied(entryId, new Dictionary { ["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 { - ["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 { ["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 { ["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 { ["residual_filters"] = "0" }, SessionId: _sessionId)); - if (_loopbackEntryId is not null) _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed", - new Dictionary { ["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 { ["residual_filters"] = "0" }, SessionId: _sessionId)); + if (_loopbackEntryId is not null) + _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "restored", + new Dictionary { ["app_container_sid"] = _appContainerSid.Value }, SessionId: _sessionId)); _disposed = true; } } diff --git a/src/Agent.Windows/Network/WfpDynamicSession.cs b/src/Agent.Windows/Network/WfpDynamicSession.cs deleted file mode 100644 index ef8ba8e..0000000 --- a/src/Agent.Windows/Network/WfpDynamicSession.cs +++ /dev/null @@ -1,208 +0,0 @@ -using System.ComponentModel; -using System.Buffers.Binary; -using System.Net; -using System.Runtime.InteropServices; -using System.Security.Principal; -using Windows.Win32; -using Windows.Win32.Foundation; -using Windows.Win32.NetworkManagement.WindowsFilteringPlatform; -using Windows.Win32.Security; - -namespace Agent.Windows.Network; - -public sealed record NetworkEndpoint(IPAddress Address, ushort Port); - -public static class NetworkAllowanceSnapshot -{ - public static async Task> ResolveAsync(IEnumerable<(string Host, IReadOnlyList Ports)> allowances, CancellationToken cancellationToken) - { - var endpoints = new HashSet(); - foreach ((string host, IReadOnlyList 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; - private readonly FwpmEngineClose0SafeHandle _engine; - private Guid? _subLayerKey; - private readonly List _filterIds = []; - - private WfpDynamicSession(FwpmEngineClose0SafeHandle engine) => _engine = engine; - public static WfpDynamicSession Open(string displayName = "Agent Sandbox MCP dynamic task session") - { - FWPM_SESSION0 session = default; - session.flags = PInvoke.FWPM_SESSION_FLAG_DYNAMIC; - uint result = PInvoke.FwpmEngineOpen0(10, null, session, out FwpmEngineClose0SafeHandle engine); - ThrowIfError(result, "FwpmEngineOpen0"); - return new(engine); - } - - public void ProbeWriteTransaction() - { - uint result = PInvoke.FwpmTransactionBegin0(_engine, 0); - ThrowIfError(result, "FwpmTransactionBegin0"); - result = PInvoke.FwpmTransactionAbort0(_engine); - ThrowIfError(result, "FwpmTransactionAbort0"); - } - - public IReadOnlyList ApplyDefaultBlock(SecurityIdentifier appContainerSid, IReadOnlyList? allowedEndpoints = null) - { - if (_subLayerKey is not null) throw new InvalidOperationException("WFP policy is already applied in this session."); - byte[] sidBytes = new byte[appContainerSid.BinaryLength]; - appContainerSid.GetBinaryForm(sidBytes, 0); - uint result = PInvoke.FwpmTransactionBegin0(_engine, 0); - ThrowIfError(result, "FwpmTransactionBegin0"); - bool committed = false; - try - { - Guid subLayerKey = Guid.NewGuid(); - fixed (char* subLayerName = "Agent Sandbox MCP task sublayer") - { - FWPM_SUBLAYER0 subLayer = default; - subLayer.subLayerKey = subLayerKey; - subLayer.displayData.name = new PWSTR(subLayerName); - subLayer.weight = 0x7FFF; - ThrowIfError(PInvoke.FwpmSubLayerAdd0(_engine, in subLayer, default), "FwpmSubLayerAdd0"); - } - - fixed (byte* sidPointer = sidBytes) - { - foreach (NetworkEndpoint endpoint in allowedEndpoints ?? []) _filterIds.Add(AddPackagePermit(endpoint, subLayerKey, (SID*)sidPointer)); - _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv4 default block")); - _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv6 default block")); - } - ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0"); - committed = true; - _subLayerKey = subLayerKey; - return _filterIds.ToArray(); - } - finally - { - if (!committed) - { - _filterIds.Clear(); - _ = PInvoke.FwpmTransactionAbort0(_engine); - } - } - } - - public void RemovePolicy() - { - if (_subLayerKey is null) return; - ThrowIfError(PInvoke.FwpmTransactionBegin0(_engine, 0), "FwpmTransactionBegin0(remove)"); - bool committed = false; - try - { - foreach (ulong filterId in _filterIds.AsEnumerable().Reverse()) ThrowIfError(PInvoke.FwpmFilterDeleteById0(_engine, filterId), "FwpmFilterDeleteById0"); - Guid subLayerKey = _subLayerKey.Value; - ThrowIfError(PInvoke.FwpmSubLayerDeleteByKey0(_engine, in subLayerKey), "FwpmSubLayerDeleteByKey0"); - ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0(remove)"); - committed = true; - _filterIds.Clear(); - _subLayerKey = null; - } - finally - { - if (!committed) _ = PInvoke.FwpmTransactionAbort0(_engine); - } - } - - public void Dispose() - { - _engine.Dispose(); - } - - private ulong AddPackageBlock(Guid layerKey, Guid subLayerKey, SID* sid, string displayName) - { - FWPM_FILTER_CONDITION0 condition = default; - condition.fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID; - condition.matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; - condition.conditionValue.type = FWP_DATA_TYPE.FWP_SID; - condition.conditionValue.sid = sid; - fixed (char* name = displayName) - { - FWPM_FILTER0 filter = default; - filter.filterKey = Guid.NewGuid(); - filter.displayData.name = new PWSTR(name); - filter.layerKey = layerKey; - filter.subLayerKey = subLayerKey; - filter.weight.type = FWP_DATA_TYPE.FWP_UINT8; - filter.weight.uint8 = 8; - filter.numFilterConditions = 1; - filter.filterCondition = &condition; - filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_BLOCK; - ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0"); - return id; - } - } - - private ulong AddPackagePermit(NetworkEndpoint endpoint, Guid subLayerKey, SID* sid) - { - FWPM_FILTER_CONDITION0* conditions = stackalloc FWPM_FILTER_CONDITION0[3]; - conditions[0].fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID; - conditions[0].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; - conditions[0].conditionValue.type = FWP_DATA_TYPE.FWP_SID; - conditions[0].conditionValue.sid = sid; - conditions[1].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS; - conditions[1].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; - Guid layer; - FWP_BYTE_ARRAY16 ipv6 = default; - if (endpoint.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) - { - layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4; - conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_UINT32; - // FWP_UINT32 uses host-order numeric values. For 127.0.0.1 the - // numeric value is 0x7F000001; parse the network-order octets into - // that value rather than reinterpreting their in-memory byte order. - conditions[1].conditionValue.uint32 = BinaryPrimitives.ReadUInt32BigEndian(endpoint.Address.GetAddressBytes()); - } - else - { - layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6; - ipv6.byteArray16 = endpoint.Address.GetAddressBytes(); - conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_BYTE_ARRAY16_TYPE; - conditions[1].conditionValue.byteArray16 = &ipv6; - } - conditions[2].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT; - conditions[2].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; - conditions[2].conditionValue.type = FWP_DATA_TYPE.FWP_UINT16; - conditions[2].conditionValue.uint16 = endpoint.Port; - string displayName = $"Agent Sandbox MCP allow {endpoint.Address}:{endpoint.Port}"; - fixed (char* name = displayName) - { - FWPM_FILTER0 filter = default; - filter.filterKey = Guid.NewGuid(); - filter.displayData.name = new PWSTR(name); - filter.layerKey = layer; - filter.subLayerKey = subLayerKey; - filter.weight.type = FWP_DATA_TYPE.FWP_UINT8; - filter.weight.uint8 = 15; - filter.numFilterConditions = 3; - filter.filterCondition = conditions; - // A plain permit is a soft permit and can be overridden by a block - // from another sub-layer (for example Windows Firewall). The exact - // task allowance must be a hard permit; our higher-weight match in - // this sub-layer still prevents the task default-block from running. - filter.flags = FwpmFilterFlagClearActionRight; - filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_PERMIT; - ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0(permit)"); - return id; - } - } - - private static void ThrowIfError(uint result, string operation) - { - if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with WFP/Win32 error 0x{result:X8}."); - } -} 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 RecoverAllAsync(CancellationToken cancellationToken) { if (!Directory.Exists(journalDirectory)) return new(0, 0, []); int journals = 0; int resources = 0; var errors = new List(); - 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 { ["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 { ["reason"] = "startup_recovery", ["message"] = ex.Message }, ErrorCode: ex.Code)); + throw; + } + audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "sandbox.elevation.success", "connected", + new Dictionary { ["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? 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 _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 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(), SessionId: sessionId)); } @@ -89,7 +65,7 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit } } - public Task ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) + public async Task 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 { ["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 { ["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 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 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 { ["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 { ["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 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 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 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 @@ -204,6 +204,27 @@ public sealed class PromptAndRuntimeTests Assert.Contains("required operation", failed.Result, StringComparison.OrdinalIgnoreCase); } + [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(["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() { @@ -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 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(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(() => AdminProtocol.ReadAsync(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(() => AdminProtocol.ReadAsync(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 @@ true + 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 InspectLoopbackAsync(string applicationSid, CancellationToken cancellationToken) => + Task.FromResult(LoopbackExemption.Contains(new SecurityIdentifier(applicationSid))); + + public Task ApplyAsync(string applicationSid, string networkAccess, IReadOnlyList 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 StartAsync(Guid taskId, CancellationToken cancellationToken) + { + Starts++; + return Task.FromResult(new DirectElevatedNetworkSession()); + } +} + +internal sealed class NoOpElevatedNetworkSessionFactory : IElevatedNetworkSessionFactory +{ + public int Starts { get; private set; } + public Task StartAsync(Guid taskId, CancellationToken cancellationToken) + { + Starts++; + return Task.FromResult(new NoOpElevatedNetworkSession()); + } + + private sealed class NoOpElevatedNetworkSession : IElevatedNetworkSession + { + public Task InspectLoopbackAsync(string applicationSid, CancellationToken cancellationToken) => Task.FromResult(false); + public Task ApplyAsync(string applicationSid, string networkAccess, IReadOnlyList 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 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); @@ -1041,6 +1045,34 @@ public sealed class SecurityPolicyTests string.Equals(path, cmd, StringComparison.OrdinalIgnoreCase))); } + [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() { @@ -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); -- cgit v1.2.3