diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-13 23:20:20 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-13 23:20:20 +0900 |
| commit | e07183ee6d3e57da325213e898cdcd6687320f53 (patch) | |
| tree | a05a71ec3ed419d54f450a0105ec22973c66a66b | |
| parent | 9337ac3e5edd42bb17f5e2ade6c2de02c7ef576d (diff) | |
test(security): close on-demand elevation acceptance gaps
Add an actual-process acceptance harness for the administrative Named Pipe boundary. Exercise production pipe creation and peer PID inspection, remote-form client rejection, Task and protocol identity mismatch, duplicate network application, oversized frames, and an intentional helper-crash recovery point without adding test-only operations to the privileged protocol.
Add a reusable PowerShell attack-matrix runner that preserves the helper parent-image check and drives the self-contained published helper through real UAC. Refactor the client pipe setup into internal diagnostic hooks while leaving the production DACL, PIPE_REJECT_REMOTE_CLIENTS, PID comparison, and helper lifetime behavior unchanged.
Record the completed UAC rejection, HTTP MCP server-continuity, helper crash, startup Recovery rejection/success, loopback restoration, WFP residue, and regression evidence. Promote the three on-demand elevation traceability rows from PARTIAL to PASS and capture the 80-test Windows Security/Recovery result and final Bundle checksum.
Verified: Release build warning/error 0; Core 105 PASS; Planner 10 PASS; administrative boundary 4 PASS; Windows Security/Recovery 80 PASS with skip 0; actual IPC attack matrix PASS; actual Task-time and Recovery-time UAC rejection PASS; Journal/backup/main/helper residue 0; fresh self-contained Bundle show PASS.
| -rw-r--r-- | AgentSandboxMcp.slnx | 1 | ||||
| -rw-r--r-- | doc/AuditReport.md | 4 | ||||
| -rw-r--r-- | plans/RequirementTraceability.md | 11 | ||||
| -rw-r--r-- | scripts/Verify-AdminIpcAttackMatrix.ps1 | 32 | ||||
| -rw-r--r-- | src/Agent.Admin/Agent.Admin.Client/ElevatedNetworkSession.cs | 35 | ||||
| -rw-r--r-- | tests/Agent.Admin.AcceptanceHarness/Agent.Admin.AcceptanceHarness.csproj | 12 | ||||
| -rw-r--r-- | tests/Agent.Admin.AcceptanceHarness/Program.cs | 183 |
7 files changed, 262 insertions, 16 deletions
diff --git a/AgentSandboxMcp.slnx b/AgentSandboxMcp.slnx index 33c59f0..276acf6 100644 --- a/AgentSandboxMcp.slnx +++ b/AgentSandboxMcp.slnx @@ -9,6 +9,7 @@ <Project Path="src/Agent.Windows/Agent.Windows.csproj" /> </Folder> <Folder Name="/tests/"> + <Project Path="tests/Agent.Admin.AcceptanceHarness/Agent.Admin.AcceptanceHarness.csproj" /> <Project Path="tests/Agent.Mcp.TestHost/Agent.Mcp.TestHost.csproj" /> <Project Path="tests/Agent.Core.Tests/Agent.Core.Tests.csproj" /> <Project Path="tests/Agent.Planner.Tests/Agent.Planner.Tests.csproj" /> diff --git a/doc/AuditReport.md b/doc/AuditReport.md index 2155236..d90c1d0 100644 --- a/doc/AuditReport.md +++ b/doc/AuditReport.md @@ -2,7 +2,9 @@ > 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 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。 + +残っていた異常系も同日のactual-Process受入で再検証した。Task時UACを実際に拒否するとHTTP MCP Taskだけが`failed/sandbox.elevation_cancelled`となり、同一Server PIDで後続`tools/list`が成功した。固定Pipeではlocal peer PID取得、remote-form接続拒否、別Task ID、別protocol version、二重apply、過大message拒否を実helperで確認した。network適用後のhelper強制終了ではApplied Journalを保持し、startup RecoveryのUAC拒否でbind前exit 2、次回承認で`recovery.end=clean`となった。Windows標準`CheckNetIsolation`と昇格`netsh wfp show state`でloopback exemption、WFP filter/sublayer、Journal、helper Processの残留0を確認した。 > 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/plans/RequirementTraceability.md b/plans/RequirementTraceability.md index 241866a..f0f47f2 100644 --- a/plans/RequirementTraceability.md +++ b/plans/RequirementTraceability.md @@ -4,9 +4,9 @@ | 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は未再検証 | +| 非昇格Server / Task時昇格 | main/admin manifest、`WindowsPlatformCheck`、遅延`process_run`初期化 | embedded manifest、通常token stdio/HTTP、実UAC、file-only無昇格、terminal error unit、2026-08-13実UAC拒否HTTP MCP | PASS | +| WFP / loopback Process境界 | `Agent.Admin.Host`、固定Named Pipe protocol | static call graph、strict schema、PID検証、none実HTTP、Windows suite、actual-Process IPC attack matrix | PASS | +| Recovery | 既存`TaskResourceJournal`と通常/昇格Recovery振分け | Windows crash matrix、legacy `wfp.dynamic`、実helper強制終了、startup Recovery拒否/成功、WFP/loopback残留0 | PASS | Sandbox Configuration schema version 2後の現行実装を対象とする。2026-08-13以前の結果は`plans/progress/`と本書末尾に履歴として保持するが、schema version 2の現行PASS判定には使用しない。 @@ -72,6 +72,11 @@ Internal Tool testは件数を仕様としてassertしない。`Design.md`のToo | 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` | +| actual UAC rejection / Server continuity | 2026-08-13 / main standard-user、UAC拒否 | HTTP MCP `agent_run` Task `0530fea1-90dd-40f1-bf7d-4deac7546ae8`が`failed/sandbox.elevation_cancelled`。同一Server PIDで直後の`tools/list`成功 | +| administrative IPC actual-Process matrix | 2026-08-13 / standard-user+Task単位helper UAC | local peer PID取得、remote-form pipe拒否、別Task ID、別protocol version、二重apply、64 KiB超messageを全件拒否 | +| helper crash / startup Recovery | 2026-08-13 / standard-user+Recovery helper UAC | network適用後にhelperを強制終了。Recovery UAC拒否はexit 2・bind前fatal・Journal保持、次回承認で`recovery.end=clean`、Journal/loopback/WFP filter/sublayer/helper残留0 | +| post-adversarial Windows Security/Recovery suite | 2026-08-13 / Windows Security childのみadministrator | 80 PASS、fail/skip 0。共有Application Profile/WFP状態を直列実行 | +| post-adversarial self-contained Bundle | 2026-08-13 / fresh extraction | `show` PASS、deploymentのlocal設定とApplied Configuration保持、ZIP SHA-256 `e473bb52cb384857012971ed545db9f7c9cc92284105930b7181e5823567944e` | | 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/Verify-AdminIpcAttackMatrix.ps1 b/scripts/Verify-AdminIpcAttackMatrix.ps1 new file mode 100644 index 0000000..c0703f0 --- /dev/null +++ b/scripts/Verify-AdminIpcAttackMatrix.ps1 @@ -0,0 +1,32 @@ +param( + [string]$Configuration = 'Release', + [string]$BundlePath = 'build\Release\publish\agent-sandbox-mcp-1.0.0-win-x64' +) + +$ErrorActionPreference = 'Stop' +$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$harnessDirectory = Join-Path $repository "build\$Configuration\Agent.Admin.AcceptanceHarness\net10.0-windows10.0.26100.0" +$sourceAppHost = Join-Path $harnessDirectory 'Agent.Admin.AcceptanceHarness.exe' +$testAppHost = Join-Path $harnessDirectory 'agent-mcp.exe' +$helper = Join-Path ([IO.Path]::GetFullPath((Join-Path $repository $BundlePath))) 'admin\agent-mcp-admin.exe' + +if (-not (Test-Path -LiteralPath $sourceAppHost) -or -not (Test-Path -LiteralPath $helper)) { + throw 'Build the solution and publish the Release Bundle before running this acceptance matrix.' +} + +# The helper independently requires the parent image name agent-mcp.exe. Renaming only +# the test apphost preserves its embedded managed DLL target while exercising that check. +Copy-Item -LiteralPath $sourceAppHost -Destination $testAppHost -Force + +& $testAppHost local-peer +if ($LASTEXITCODE -ne 0) { throw 'Local peer PID authentication failed.' } +& $testAppHost remote-peer +if ($LASTEXITCODE -ne 0) { throw 'Remote-form Named Pipe rejection failed.' } + +Write-Host 'Four UAC prompts follow. Approve each prompt to test the real elevated helper.' -ForegroundColor Yellow +foreach ($scenario in 'wrong-task', 'wrong-version', 'double-apply', 'oversized') { + & $testAppHost $scenario $helper + if ($LASTEXITCODE -ne 0) { throw "Administrative IPC scenario '$scenario' failed." } +} + +Write-Output 'Administrative IPC actual-Process attack matrix PASS.' diff --git a/src/Agent.Admin/Agent.Admin.Client/ElevatedNetworkSession.cs b/src/Agent.Admin/Agent.Admin.Client/ElevatedNetworkSession.cs index 57339dc..6caa29b 100644 --- a/src/Agent.Admin/Agent.Admin.Client/ElevatedNetworkSession.cs +++ b/src/Agent.Admin/Agent.Admin.Client/ElevatedNetworkSession.cs @@ -41,17 +41,7 @@ public sealed partial class ElevatedNetworkSessionFactory(string helperPath) : I 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); + NamedPipeServerStream pipe = CreateLocalPipeForCurrentLogon(pipeName); Process? helper = null; try { @@ -86,7 +76,7 @@ public sealed partial class ElevatedNetworkSessionFactory(string helperPath) : I await connection.ConfigureAwait(false); linked.Cancel(); - if (!Native.GetNamedPipeClientProcessId(pipe.SafePipeHandle, out uint clientPid) || clientPid != helper.Id || helper.HasExited) + if (!TryGetClientProcessId(pipe, 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); @@ -101,6 +91,24 @@ public sealed partial class ElevatedNetworkSessionFactory(string helperPath) : I } } + internal static NamedPipeServerStream CreateLocalPipeForCurrentLogon(string pipeName) + { + 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)); + + return CreateLocalPipe(pipeName, security); + } + + internal static bool TryGetClientProcessId(NamedPipeServerStream pipe, out uint clientPid) => + Native.GetNamedPipeClientProcessId(pipe.SafePipeHandle, out clientPid); + private static string Quote(string value) => '"' + value.Replace("\"", "\\\"") + '"'; private static SecurityIdentifier GetLogonSid(WindowsIdentity identity) @@ -186,6 +194,9 @@ internal sealed class ElevatedNetworkSession(NamedPipeServerStream pipe, Process { private bool _cleaned; + internal NamedPipeServerStream Pipe => pipe; + internal Process HelperProcess => helper; + public async Task<bool> InspectLoopbackAsync(string applicationSid, CancellationToken cancellationToken) { AdminResponse response = await SendAsync(new(AdminProtocol.Version, "inspect-loopback", taskId, applicationSid), cancellationToken).ConfigureAwait(false); diff --git a/tests/Agent.Admin.AcceptanceHarness/Agent.Admin.AcceptanceHarness.csproj b/tests/Agent.Admin.AcceptanceHarness/Agent.Admin.AcceptanceHarness.csproj new file mode 100644 index 0000000..cbca477 --- /dev/null +++ b/tests/Agent.Admin.AcceptanceHarness/Agent.Admin.AcceptanceHarness.csproj @@ -0,0 +1,12 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <OutputType>Exe</OutputType> + <AssemblyName>Agent.Admin.AcceptanceHarness</AssemblyName> + <RootNamespace>Agent.Admin.AcceptanceHarness</RootNamespace> + <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\src\Agent.Admin\Agent.Admin.Client\Agent.Admin.Client.csproj" /> + <ProjectReference Include="..\..\src\Agent.Windows\Agent.Windows.csproj" /> + </ItemGroup> +</Project> diff --git a/tests/Agent.Admin.AcceptanceHarness/Program.cs b/tests/Agent.Admin.AcceptanceHarness/Program.cs new file mode 100644 index 0000000..046d741 --- /dev/null +++ b/tests/Agent.Admin.AcceptanceHarness/Program.cs @@ -0,0 +1,183 @@ +using System.Buffers.Binary; +using System.Diagnostics; +using System.IO.Pipes; +using System.Net; +using System.Reflection; +using Agent.Admin.Client; +using Agent.Windows.Network; +using Agent.Windows.Recovery; +using Agent.Windows.Security; + +if (args.Length == 0) return Usage(); +try +{ + switch (args[0]) + { + case "local-peer": + await VerifyLocalPeerAsync(); + break; + case "remote-peer": + await VerifyRemotePeerAsync(); + break; + case "connect": + return await ConnectAsync(args); + case "wrong-task": + case "wrong-version": + case "double-apply": + case "oversized": + Require(args, 2); + await VerifyHostRejectsAsync(args[0], Path.GetFullPath(args[1])); + break; + case "crash-local": + Require(args, 4); + await LeaveCrashJournalAsync(Path.GetFullPath(args[1]), Path.GetFullPath(args[2]), Path.GetFullPath(args[3])); + break; + default: + return Usage(); + } + Console.WriteLine($"PASS {args[0]}"); + return 0; +} +catch (Exception ex) +{ + Console.Error.WriteLine(ex); + return 1; +} + +static int Usage() +{ + Console.Error.WriteLine("Internal acceptance harness. See scripts/Verify-AdminIpcAttackMatrix.ps1."); + return 2; +} + +static void Require(string[] values, int count) +{ + if (values.Length != count) throw new ArgumentException("Invalid acceptance harness arguments."); +} + +static async Task VerifyLocalPeerAsync() +{ + string pipeName = $"AgentSandboxMcp-Acceptance-{Guid.NewGuid():N}"; + await using NamedPipeServerStream pipe = CreateAcceptancePipe(pipeName); + using Process child = StartSelf("connect", ".", pipeName, "success"); + await pipe.WaitForConnectionAsync().WaitAsync(TimeSpan.FromSeconds(10)); + uint clientPid = GetClientProcessId(pipe); + if (clientPid != child.Id || clientPid == Environment.ProcessId) + throw new InvalidOperationException("The actual local Named Pipe peer PID was not authenticated."); + await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + if (child.ExitCode != 0) throw new InvalidOperationException($"Local peer exited with {child.ExitCode}."); +} + +static async Task VerifyRemotePeerAsync() +{ + string pipeName = $"AgentSandboxMcp-Acceptance-{Guid.NewGuid():N}"; + await using NamedPipeServerStream pipe = CreateAcceptancePipe(pipeName); + using Process child = StartSelf("connect", Environment.MachineName, pipeName, "rejected"); + await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + if (child.ExitCode != 0) throw new InvalidOperationException($"Remote-form peer was not rejected (exit {child.ExitCode})."); + if (pipe.IsConnected) throw new InvalidOperationException("PIPE_REJECT_REMOTE_CLIENTS accepted a remote-form connection."); +} + +static async Task<int> ConnectAsync(string[] values) +{ + Require(values, 4); + bool expectSuccess = values[3] == "success"; + try + { + await using var pipe = new NamedPipeClientStream(values[1], values[2], PipeDirection.InOut, PipeOptions.Asynchronous); + await pipe.ConnectAsync(2_000); + return expectSuccess ? 0 : 1; + } + catch (Exception) when (!expectSuccess) + { + return 0; + } +} + +static Process StartSelf(params string[] arguments) +{ + var start = new ProcessStartInfo { FileName = Environment.ProcessPath!, UseShellExecute = false }; + foreach (string argument in arguments) start.ArgumentList.Add(argument); + return Process.Start(start) ?? throw new InvalidOperationException("Acceptance child did not start."); +} + +static NamedPipeServerStream CreateAcceptancePipe(string pipeName) => + (NamedPipeServerStream)(typeof(ElevatedNetworkSessionFactory).GetMethod("CreateLocalPipeForCurrentLogon", + BindingFlags.Static | BindingFlags.NonPublic)?.Invoke(null, [pipeName]) + ?? throw new MissingMethodException("Production pipe factory test hook was not found.")); + +static uint GetClientProcessId(NamedPipeServerStream pipe) +{ + object?[] arguments = [pipe, 0u]; + bool success = (bool)(typeof(ElevatedNetworkSessionFactory).GetMethod("TryGetClientProcessId", + BindingFlags.Static | BindingFlags.NonPublic)?.Invoke(null, arguments) ?? false); + if (!success) throw new InvalidOperationException("GetNamedPipeClientProcessId failed."); + return (uint)arguments[1]!; +} + +static NamedPipeServerStream SessionPipe(IElevatedNetworkSession session) => + (NamedPipeServerStream)(session.GetType().GetProperty("Pipe", BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(session) + ?? throw new MissingMemberException("Elevated session pipe test hook was not found.")); + +static Process SessionHelper(IElevatedNetworkSession session) => + (Process)(session.GetType().GetProperty("HelperProcess", BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(session) + ?? throw new MissingMemberException("Elevated session Process test hook was not found.")); + +static async Task VerifyHostRejectsAsync(string mode, string helperPath) +{ + Guid taskId = Guid.NewGuid(); + var factory = new ElevatedNetworkSessionFactory(helperPath); + await using IElevatedNetworkSession abstraction = await factory.StartAsync(taskId, CancellationToken.None); + IElevatedNetworkSession session = abstraction; + NamedPipeServerStream pipe = SessionPipe(session); + Process helper = SessionHelper(session); + string sid = AppContainerProfile.EnsureFixed().Sid.Value; + if (mode == "double-apply") + { + await session.ApplyAsync(sid, "none", [], false, CancellationToken.None); + await AssertRejectedAsync(pipe, new(AdminProtocol.Version, "apply-network", taskId, sid, "none", [], false)); + return; + } + if (mode == "oversized") + { + byte[] header = new byte[sizeof(int)]; + BinaryPrimitives.WriteInt32LittleEndian(header, AdminProtocol.MaxMessageBytes + 1); + await pipe.WriteAsync(header); + await pipe.FlushAsync(); + await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + if (helper.ExitCode != 2) throw new InvalidOperationException("Oversized message did not terminate the helper with its rejection exit code."); + return; + } + AdminRequest request = mode == "wrong-task" + ? new(AdminProtocol.Version, "inspect-loopback", Guid.NewGuid(), sid) + : new(AdminProtocol.Version + 1, "inspect-loopback", taskId, sid); + await AssertRejectedAsync(pipe, request); +} + +static async Task AssertRejectedAsync(NamedPipeServerStream pipe, AdminRequest request) +{ + await AdminProtocol.WriteAsync(pipe, request, CancellationToken.None); + AdminResponse response = await AdminProtocol.ReadAsync<AdminResponse>(pipe, CancellationToken.None); + if (response.Success || response.ErrorCode != "sandbox.ipc_rejected") + throw new InvalidOperationException("The hostile administrative request was not rejected canonically."); +} + +static async Task LeaveCrashJournalAsync(string helperPath, string journalRoot, string readyPath) +{ + Guid taskId = Guid.NewGuid(); + var factory = new ElevatedNetworkSessionFactory(helperPath); + IElevatedNetworkSession abstraction = await factory.StartAsync(taskId, CancellationToken.None); + IElevatedNetworkSession session = abstraction; + Process helper = SessionHelper(session); + string sid = AppContainerProfile.EnsureFixed().Sid.Value; + bool existing = await session.InspectLoopbackAsync(sid, CancellationToken.None); + if (existing) throw new InvalidOperationException("The fixed Application SID already has a loopback exemption; recover it before this scenario."); + TaskResourceJournal journal = TaskResourceJournal.Create(journalRoot, taskId); + _ = await TaskNetworkPolicy.ApplyAsync(session, journal, AppContainerProfile.EnsureFixed().Sid, "local", + [new NetworkEndpoint(IPAddress.Loopback, 11434), new NetworkEndpoint(IPAddress.IPv6Loopback, 11434)], + new MemorySecurityAuditSink(), null, CancellationToken.None); + await File.WriteAllTextAsync(readyPath, $"task_id={taskId:D}\nhelper_pid={helper.Id}\n"); + await helper.WaitForExitAsync(); + // Intentionally do not dispose the Task Network Policy. The killed helper owns the + // dynamic WFP session; the persistent loopback PREPARE/APPLIED record must survive. +} |
