summaryrefslogtreecommitdiff
path: root/tests/Agent.Admin.AcceptanceHarness
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-13 23:20:20 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-13 23:20:20 +0900
commite07183ee6d3e57da325213e898cdcd6687320f53 (patch)
treea05a71ec3ed419d54f450a0105ec22973c66a66b /tests/Agent.Admin.AcceptanceHarness
parent9337ac3e5edd42bb17f5e2ade6c2de02c7ef576d (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.
Diffstat (limited to 'tests/Agent.Admin.AcceptanceHarness')
-rw-r--r--tests/Agent.Admin.AcceptanceHarness/Agent.Admin.AcceptanceHarness.csproj12
-rw-r--r--tests/Agent.Admin.AcceptanceHarness/Program.cs183
2 files changed, 195 insertions, 0 deletions
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.
+}