summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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.cs (renamed from src/Agent.Windows/Network/LoopbackExemption.cs)2
-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.cs (renamed from src/Agent.Windows/Network/WfpDynamicSession.cs)21
-rw-r--r--src/Agent.Admin/Agent.Admin.Host/app.manifest10
-rw-r--r--src/Agent.App/Agent.App.csproj2
-rw-r--r--src/Agent.App/Program.cs27
-rw-r--r--src/Agent.App/app.manifest10
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs22
-rw-r--r--src/Agent.Windows/Agent.Windows.csproj1
-rw-r--r--src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs64
-rw-r--r--src/Agent.Windows/NativeMethods.txt26
-rw-r--r--src/Agent.Windows/Network/NetworkAllowanceSnapshot.cs27
-rw-r--r--src/Agent.Windows/Network/TaskNetworkPolicy.cs83
-rw-r--r--src/Agent.Windows/Recovery/RecoveryManager.cs52
-rw-r--r--src/Agent.Windows/Security/AppContainerProfile.cs8
-rw-r--r--src/Agent.Windows/Security/TaskAclManager.cs27
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs160
22 files changed, 912 insertions, 192 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.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; }
}
}