From e07183ee6d3e57da325213e898cdcd6687320f53 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Thu, 13 Aug 2026 23:20:20 +0900 Subject: 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. --- tests/Agent.Admin.AcceptanceHarness/Program.cs | 183 +++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/Agent.Admin.AcceptanceHarness/Program.cs (limited to 'tests/Agent.Admin.AcceptanceHarness/Program.cs') 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 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(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. +} -- cgit v1.2.3