diff options
Diffstat (limited to 'src')
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; } } } |
