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