summaryrefslogtreecommitdiff
path: root/src/Agent.Admin
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-13 22:48:24 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-13 22:48:24 +0900
commit9337ac3e5edd42bb17f5e2ade6c2de02c7ef576d (patch)
tree9a48fefc88cc046f4ce1e2bc34fa0a47351e4f83 /src/Agent.Admin
parent36ae1a4cba791ec633a81b38b1d8345af8487276 (diff)
feat!: isolate privileged sandbox network operations per task
Run agent-mcp with an explicit asInvoker manifest and move WFP and loopback mutations into a fixed requireAdministrator helper under the release admin directory. Add the Agent.Admin client and host projects with task-scoped runas launch, a logon-SID and Administrators-only local named pipe, remote-client rejection, mutual PID validation, strict versioned JSON framing, fixed network-only operations, and bounded messages. Delay ACL and network Task Resource application until the first process_run. Verify executable identity, managed entry points, DNS snapshots, and normal-token WRITE_DAC access before elevation; reuse the helper only within the Task and keep elevation failures terminal without policy replanning. Keep LPAC creation, executable approval, Job confinement, and ACL leases in the normal process. Make startup platform checks read-only, create the fixed Application Profile during apply, preserve dynamic WFP lifetime in the helper, and route persistent loopback recovery through the same elevated boundary. Update recovery journaling, localized elevation and IPC errors, audit events, release publishing, manifests, README guidance, Specifications, Design, implementation records, and requirement traceability for the on-demand elevation model. Add administrative-boundary, file-only no-elevation, elevation-terminal, lazy-resource, crash-recovery, manifest, IPC schema, and operational verification coverage. Verified Release build with zero warnings, Core 105/105, Planner 10/10, administrative boundary 4/4, elevated Windows suite 79/79, and published stdio/HTTP MCP E2E.
Diffstat (limited to 'src/Agent.Admin')
-rw-r--r--src/Agent.Admin/Agent.Admin.Client/AdminProtocol.cs61
-rw-r--r--src/Agent.Admin/Agent.Admin.Client/Agent.Admin.Client.csproj7
-rw-r--r--src/Agent.Admin/Agent.Admin.Client/ElevatedNetworkSession.cs237
-rw-r--r--src/Agent.Admin/Agent.Admin.Host/Agent.Admin.Host.csproj19
-rw-r--r--src/Agent.Admin/Agent.Admin.Host/LoopbackExemption.cs123
-rw-r--r--src/Agent.Admin/Agent.Admin.Host/NativeMethods.txt27
-rw-r--r--src/Agent.Admin/Agent.Admin.Host/Program.cs211
-rw-r--r--src/Agent.Admin/Agent.Admin.Host/WfpDynamicSession.cs189
-rw-r--r--src/Agent.Admin/Agent.Admin.Host/app.manifest10
9 files changed, 884 insertions, 0 deletions
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.Admin/Agent.Admin.Host/LoopbackExemption.cs b/src/Agent.Admin/Agent.Admin.Host/LoopbackExemption.cs
new file mode 100644
index 0000000..3449491
--- /dev/null
+++ b/src/Agent.Admin/Agent.Admin.Host/LoopbackExemption.cs
@@ -0,0 +1,123 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+using Windows.Win32.Security;
+using Windows.Win32.System.Memory;
+
+namespace Agent.Admin.Host;
+
+internal static unsafe class LoopbackExemption
+{
+ private const string MutexName = "Local\\AgentSandboxMcpLoopbackExemption";
+
+ public static bool Contains(SecurityIdentifier sid)
+ {
+ using MutexLease mutex = AcquireMutex();
+ return Read().Any(item => item.Sid.Equals(sid));
+ }
+
+ public static bool Ensure(SecurityIdentifier sid)
+ {
+ using MutexLease mutex = AcquireMutex();
+ List<Entry> entries = Read();
+ if (entries.Any(item => item.Sid.Equals(sid))) return false;
+ entries.Add(new(sid, 0));
+ Write(entries);
+ return true;
+ }
+
+ public static void Remove(SecurityIdentifier sid)
+ {
+ using MutexLease mutex = AcquireMutex();
+ List<Entry> entries = Read();
+ if (entries.RemoveAll(item => item.Sid.Equals(sid)) == 0) return;
+ Write(entries);
+ }
+
+ private static MutexLease AcquireMutex()
+ {
+ var mutex = new Mutex(false, MutexName);
+ try
+ {
+ try
+ {
+ if (!mutex.WaitOne(TimeSpan.FromSeconds(30))) throw new TimeoutException("Timed out waiting for the loopback exemption lock.");
+ }
+ catch (AbandonedMutexException)
+ {
+ // Ownership is granted to this thread when the prior owner died.
+ }
+ return new(mutex);
+ }
+ catch
+ {
+ mutex.Dispose();
+ throw;
+ }
+ }
+
+ private static List<Entry> Read()
+ {
+ uint result = PInvoke.NetworkIsolationGetAppContainerConfig(out uint count, out SID_AND_ATTRIBUTES* native);
+ ThrowIfError(result, "NetworkIsolationGetAppContainerConfig");
+ try
+ {
+ var entries = new List<Entry>(checked((int)count));
+ for (uint index = 0; index < count; index++)
+ {
+ SID_AND_ATTRIBUTES item = native[index];
+ entries.Add(new(new SecurityIdentifier((nint)item.Sid.Value), item.Attributes));
+ }
+ return entries;
+ }
+ finally
+ {
+ HANDLE heap = PInvoke.GetProcessHeap();
+ for (uint index = 0; index < count; index++)
+ if (native[index].Sid.Value is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native[index].Sid.Value);
+ if (native is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native);
+ }
+ }
+
+ private static void Write(IReadOnlyList<Entry> entries)
+ {
+ var native = new SID_AND_ATTRIBUTES[entries.Count];
+ var allocations = new List<nint>(entries.Count);
+ try
+ {
+ for (int index = 0; index < entries.Count; index++)
+ {
+ byte[] bytes = new byte[entries[index].Sid.BinaryLength];
+ entries[index].Sid.GetBinaryForm(bytes, 0);
+ void* allocation = NativeMemory.Alloc((nuint)bytes.Length);
+ Marshal.Copy(bytes, 0, (nint)allocation, bytes.Length);
+ allocations.Add((nint)allocation);
+ native[index].Sid = new PSID(allocation);
+ native[index].Attributes = entries[index].Attributes;
+ }
+ ThrowIfError(PInvoke.NetworkIsolationSetAppContainerConfig(native), "NetworkIsolationSetAppContainerConfig");
+ }
+ finally
+ {
+ foreach (nint allocation in allocations) NativeMemory.Free((void*)allocation);
+ }
+ }
+
+ private static void ThrowIfError(uint result, string operation)
+ {
+ if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with Win32 error 0x{result:X8}.");
+ }
+
+ private sealed record Entry(SecurityIdentifier Sid, uint Attributes);
+
+ private sealed class MutexLease(Mutex mutex) : IDisposable
+ {
+ public void Dispose()
+ {
+ mutex.ReleaseMutex();
+ mutex.Dispose();
+ }
+ }
+}
diff --git a/src/Agent.Admin/Agent.Admin.Host/NativeMethods.txt b/src/Agent.Admin/Agent.Admin.Host/NativeMethods.txt
new file mode 100644
index 0000000..cb038a6
--- /dev/null
+++ b/src/Agent.Admin/Agent.Admin.Host/NativeMethods.txt
@@ -0,0 +1,27 @@
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineOpen0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineClose0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionBegin0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionCommit0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionAbort0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmSubLayerAdd0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmSubLayerDeleteByKey0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmFilterAdd0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmFilterDeleteById0
+FWPM_SESSION0
+FWPM_SESSION_FLAG_DYNAMIC
+FWPM_SUBLAYER0
+FWPM_FILTER0
+FWPM_FILTER_CONDITION0
+FWPM_LAYER_ALE_AUTH_CONNECT_V4
+FWPM_LAYER_ALE_AUTH_CONNECT_V6
+FWPM_CONDITION_ALE_PACKAGE_ID
+FWPM_CONDITION_IP_REMOTE_ADDRESS
+FWPM_CONDITION_IP_REMOTE_PORT
+FWP_BYTE_ARRAY16
+FWP_ACTION_TYPE
+FWP_MATCH_TYPE
+FWP_DATA_TYPE
+NetworkIsolationGetAppContainerConfig
+NetworkIsolationSetAppContainerConfig
+GetProcessHeap
+HeapFree
diff --git a/src/Agent.Admin/Agent.Admin.Host/Program.cs b/src/Agent.Admin/Agent.Admin.Host/Program.cs
new file mode 100644
index 0000000..635060e
--- /dev/null
+++ b/src/Agent.Admin/Agent.Admin.Host/Program.cs
@@ -0,0 +1,211 @@
+using System.Diagnostics;
+using System.IO.Pipes;
+using System.Net;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+using Agent.Admin.Client;
+using Microsoft.Win32.SafeHandles;
+
+namespace Agent.Admin.Host;
+
+internal static partial class Program
+{
+ private const string FixedProfileName = "AgentSandboxMcp.V1";
+
+ public static async Task<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.Admin/Agent.Admin.Host/WfpDynamicSession.cs b/src/Agent.Admin/Agent.Admin.Host/WfpDynamicSession.cs
new file mode 100644
index 0000000..4aa21da
--- /dev/null
+++ b/src/Agent.Admin/Agent.Admin.Host/WfpDynamicSession.cs
@@ -0,0 +1,189 @@
+using System.ComponentModel;
+using System.Buffers.Binary;
+using System.Net;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+using Windows.Win32.NetworkManagement.WindowsFilteringPlatform;
+using Windows.Win32.Security;
+
+namespace Agent.Admin.Host;
+
+public sealed record NetworkEndpoint(IPAddress Address, ushort Port);
+
+public sealed unsafe class WfpDynamicSession : IDisposable
+{
+ private const FWPM_FILTER_FLAGS FwpmFilterFlagClearActionRight = (FWPM_FILTER_FLAGS)0x00000008;
+ private readonly FwpmEngineClose0SafeHandle _engine;
+ private Guid? _subLayerKey;
+ private readonly List<ulong> _filterIds = [];
+
+ private WfpDynamicSession(FwpmEngineClose0SafeHandle engine) => _engine = engine;
+ public static WfpDynamicSession Open(string displayName = "Agent Sandbox MCP dynamic task session")
+ {
+ FWPM_SESSION0 session = default;
+ session.flags = PInvoke.FWPM_SESSION_FLAG_DYNAMIC;
+ uint result = PInvoke.FwpmEngineOpen0(10, null, session, out FwpmEngineClose0SafeHandle engine);
+ ThrowIfError(result, "FwpmEngineOpen0");
+ return new(engine);
+ }
+
+ public void ProbeWriteTransaction()
+ {
+ uint result = PInvoke.FwpmTransactionBegin0(_engine, 0);
+ ThrowIfError(result, "FwpmTransactionBegin0");
+ result = PInvoke.FwpmTransactionAbort0(_engine);
+ ThrowIfError(result, "FwpmTransactionAbort0");
+ }
+
+ public IReadOnlyList<ulong> ApplyDefaultBlock(SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint>? allowedEndpoints = null)
+ {
+ if (_subLayerKey is not null) throw new InvalidOperationException("WFP policy is already applied in this session.");
+ byte[] sidBytes = new byte[appContainerSid.BinaryLength];
+ appContainerSid.GetBinaryForm(sidBytes, 0);
+ uint result = PInvoke.FwpmTransactionBegin0(_engine, 0);
+ ThrowIfError(result, "FwpmTransactionBegin0");
+ bool committed = false;
+ try
+ {
+ Guid subLayerKey = Guid.NewGuid();
+ fixed (char* subLayerName = "Agent Sandbox MCP task sublayer")
+ {
+ FWPM_SUBLAYER0 subLayer = default;
+ subLayer.subLayerKey = subLayerKey;
+ subLayer.displayData.name = new PWSTR(subLayerName);
+ subLayer.weight = 0x7FFF;
+ ThrowIfError(PInvoke.FwpmSubLayerAdd0(_engine, in subLayer, default), "FwpmSubLayerAdd0");
+ }
+
+ fixed (byte* sidPointer = sidBytes)
+ {
+ foreach (NetworkEndpoint endpoint in allowedEndpoints ?? []) _filterIds.Add(AddPackagePermit(endpoint, subLayerKey, (SID*)sidPointer));
+ _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv4 default block"));
+ _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv6 default block"));
+ }
+ ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0");
+ committed = true;
+ _subLayerKey = subLayerKey;
+ return _filterIds.ToArray();
+ }
+ finally
+ {
+ if (!committed)
+ {
+ _filterIds.Clear();
+ _ = PInvoke.FwpmTransactionAbort0(_engine);
+ }
+ }
+ }
+
+ public void RemovePolicy()
+ {
+ if (_subLayerKey is null) return;
+ ThrowIfError(PInvoke.FwpmTransactionBegin0(_engine, 0), "FwpmTransactionBegin0(remove)");
+ bool committed = false;
+ try
+ {
+ foreach (ulong filterId in _filterIds.AsEnumerable().Reverse()) ThrowIfError(PInvoke.FwpmFilterDeleteById0(_engine, filterId), "FwpmFilterDeleteById0");
+ Guid subLayerKey = _subLayerKey.Value;
+ ThrowIfError(PInvoke.FwpmSubLayerDeleteByKey0(_engine, in subLayerKey), "FwpmSubLayerDeleteByKey0");
+ ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0(remove)");
+ committed = true;
+ _filterIds.Clear();
+ _subLayerKey = null;
+ }
+ finally
+ {
+ if (!committed) _ = PInvoke.FwpmTransactionAbort0(_engine);
+ }
+ }
+
+ public void Dispose()
+ {
+ _engine.Dispose();
+ }
+
+ private ulong AddPackageBlock(Guid layerKey, Guid subLayerKey, SID* sid, string displayName)
+ {
+ FWPM_FILTER_CONDITION0 condition = default;
+ condition.fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID;
+ condition.matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL;
+ condition.conditionValue.type = FWP_DATA_TYPE.FWP_SID;
+ condition.conditionValue.sid = sid;
+ fixed (char* name = displayName)
+ {
+ FWPM_FILTER0 filter = default;
+ filter.filterKey = Guid.NewGuid();
+ filter.displayData.name = new PWSTR(name);
+ filter.layerKey = layerKey;
+ filter.subLayerKey = subLayerKey;
+ filter.weight.type = FWP_DATA_TYPE.FWP_UINT8;
+ filter.weight.uint8 = 8;
+ filter.numFilterConditions = 1;
+ filter.filterCondition = &condition;
+ filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_BLOCK;
+ ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0");
+ return id;
+ }
+ }
+
+ private ulong AddPackagePermit(NetworkEndpoint endpoint, Guid subLayerKey, SID* sid)
+ {
+ FWPM_FILTER_CONDITION0* conditions = stackalloc FWPM_FILTER_CONDITION0[3];
+ conditions[0].fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID;
+ conditions[0].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL;
+ conditions[0].conditionValue.type = FWP_DATA_TYPE.FWP_SID;
+ conditions[0].conditionValue.sid = sid;
+ conditions[1].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS;
+ conditions[1].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL;
+ Guid layer;
+ FWP_BYTE_ARRAY16 ipv6 = default;
+ if (endpoint.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
+ {
+ layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4;
+ conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_UINT32;
+ // FWP_UINT32 uses host-order numeric values. For 127.0.0.1 the
+ // numeric value is 0x7F000001; parse the network-order octets into
+ // that value rather than reinterpreting their in-memory byte order.
+ conditions[1].conditionValue.uint32 = BinaryPrimitives.ReadUInt32BigEndian(endpoint.Address.GetAddressBytes());
+ }
+ else
+ {
+ layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6;
+ ipv6.byteArray16 = endpoint.Address.GetAddressBytes();
+ conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_BYTE_ARRAY16_TYPE;
+ conditions[1].conditionValue.byteArray16 = &ipv6;
+ }
+ conditions[2].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT;
+ conditions[2].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL;
+ conditions[2].conditionValue.type = FWP_DATA_TYPE.FWP_UINT16;
+ conditions[2].conditionValue.uint16 = endpoint.Port;
+ string displayName = $"Agent Sandbox MCP allow {endpoint.Address}:{endpoint.Port}";
+ fixed (char* name = displayName)
+ {
+ FWPM_FILTER0 filter = default;
+ filter.filterKey = Guid.NewGuid();
+ filter.displayData.name = new PWSTR(name);
+ filter.layerKey = layer;
+ filter.subLayerKey = subLayerKey;
+ filter.weight.type = FWP_DATA_TYPE.FWP_UINT8;
+ filter.weight.uint8 = 15;
+ filter.numFilterConditions = 3;
+ filter.filterCondition = conditions;
+ // A plain permit is a soft permit and can be overridden by a block
+ // from another sub-layer (for example Windows Firewall). The exact
+ // task allowance must be a hard permit; our higher-weight match in
+ // this sub-layer still prevents the task default-block from running.
+ filter.flags = FwpmFilterFlagClearActionRight;
+ filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_PERMIT;
+ ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0(permit)");
+ return id;
+ }
+ }
+
+ private static void ThrowIfError(uint result, string operation)
+ {
+ if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with WFP/Win32 error 0x{result:X8}.");
+ }
+}
diff --git a/src/Agent.Admin/Agent.Admin.Host/app.manifest b/src/Agent.Admin/Agent.Admin.Host/app.manifest
new file mode 100644
index 0000000..9ee29da
--- /dev/null
+++ b/src/Agent.Admin/Agent.Admin.Host/app.manifest
@@ -0,0 +1,10 @@
+<?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>