diff options
Diffstat (limited to 'src/Agent.Windows')
| -rw-r--r-- | src/Agent.Windows/Agent.Windows.csproj | 1 | ||||
| -rw-r--r-- | src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs | 64 | ||||
| -rw-r--r-- | src/Agent.Windows/NativeMethods.txt | 26 | ||||
| -rw-r--r-- | src/Agent.Windows/Network/LoopbackExemption.cs | 123 | ||||
| -rw-r--r-- | src/Agent.Windows/Network/NetworkAllowanceSnapshot.cs | 27 | ||||
| -rw-r--r-- | src/Agent.Windows/Network/TaskNetworkPolicy.cs | 83 | ||||
| -rw-r--r-- | src/Agent.Windows/Network/WfpDynamicSession.cs | 208 | ||||
| -rw-r--r-- | src/Agent.Windows/Recovery/RecoveryManager.cs | 52 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/AppContainerProfile.cs | 8 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/TaskAclManager.cs | 27 | ||||
| -rw-r--r-- | src/Agent.Windows/Tools/WindowsToolBackend.cs | 160 |
11 files changed, 285 insertions, 494 deletions
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/LoopbackExemption.cs b/src/Agent.Windows/Network/LoopbackExemption.cs deleted file mode 100644 index a3c68ba..0000000 --- a/src/Agent.Windows/Network/LoopbackExemption.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.ComponentModel; -using System.Runtime.InteropServices; -using System.Security.Principal; -using Windows.Win32; -using Windows.Win32.Foundation; -using Windows.Win32.Security; -using Windows.Win32.System.Memory; - -namespace Agent.Windows.Network; - -internal static unsafe class LoopbackExemption -{ - private const string MutexName = "Local\\AgentSandboxMcpLoopbackExemption"; - - public static bool Contains(SecurityIdentifier sid) - { - using MutexLease mutex = AcquireMutex(); - return Read().Any(item => item.Sid.Equals(sid)); - } - - public static bool Ensure(SecurityIdentifier sid) - { - using MutexLease mutex = AcquireMutex(); - List<Entry> entries = Read(); - if (entries.Any(item => item.Sid.Equals(sid))) return false; - entries.Add(new(sid, 0)); - Write(entries); - return true; - } - - public static void Remove(SecurityIdentifier sid) - { - using MutexLease mutex = AcquireMutex(); - List<Entry> entries = Read(); - if (entries.RemoveAll(item => item.Sid.Equals(sid)) == 0) return; - Write(entries); - } - - private static MutexLease AcquireMutex() - { - var mutex = new Mutex(false, MutexName); - try - { - try - { - if (!mutex.WaitOne(TimeSpan.FromSeconds(30))) throw new TimeoutException("Timed out waiting for the loopback exemption lock."); - } - catch (AbandonedMutexException) - { - // Ownership is granted to this thread when the prior owner died. - } - return new(mutex); - } - catch - { - mutex.Dispose(); - throw; - } - } - - private static List<Entry> Read() - { - uint result = PInvoke.NetworkIsolationGetAppContainerConfig(out uint count, out SID_AND_ATTRIBUTES* native); - ThrowIfError(result, "NetworkIsolationGetAppContainerConfig"); - try - { - var entries = new List<Entry>(checked((int)count)); - for (uint index = 0; index < count; index++) - { - SID_AND_ATTRIBUTES item = native[index]; - entries.Add(new(new SecurityIdentifier((nint)item.Sid.Value), item.Attributes)); - } - return entries; - } - finally - { - HANDLE heap = PInvoke.GetProcessHeap(); - for (uint index = 0; index < count; index++) - if (native[index].Sid.Value is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native[index].Sid.Value); - if (native is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native); - } - } - - private static void Write(IReadOnlyList<Entry> entries) - { - var native = new SID_AND_ATTRIBUTES[entries.Count]; - var allocations = new List<nint>(entries.Count); - try - { - for (int index = 0; index < entries.Count; index++) - { - byte[] bytes = new byte[entries[index].Sid.BinaryLength]; - entries[index].Sid.GetBinaryForm(bytes, 0); - void* allocation = NativeMemory.Alloc((nuint)bytes.Length); - Marshal.Copy(bytes, 0, (nint)allocation, bytes.Length); - allocations.Add((nint)allocation); - native[index].Sid = new PSID(allocation); - native[index].Attributes = entries[index].Attributes; - } - ThrowIfError(PInvoke.NetworkIsolationSetAppContainerConfig(native), "NetworkIsolationSetAppContainerConfig"); - } - finally - { - foreach (nint allocation in allocations) NativeMemory.Free((void*)allocation); - } - } - - private static void ThrowIfError(uint result, string operation) - { - if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with Win32 error 0x{result:X8}."); - } - - private sealed record Entry(SecurityIdentifier Sid, uint Attributes); - - private sealed class MutexLease(Mutex mutex) : IDisposable - { - public void Dispose() - { - mutex.ReleaseMutex(); - mutex.Dispose(); - } - } -} diff --git a/src/Agent.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/Network/WfpDynamicSession.cs b/src/Agent.Windows/Network/WfpDynamicSession.cs deleted file mode 100644 index ef8ba8e..0000000 --- a/src/Agent.Windows/Network/WfpDynamicSession.cs +++ /dev/null @@ -1,208 +0,0 @@ -using System.ComponentModel; -using System.Buffers.Binary; -using System.Net; -using System.Runtime.InteropServices; -using System.Security.Principal; -using Windows.Win32; -using Windows.Win32.Foundation; -using Windows.Win32.NetworkManagement.WindowsFilteringPlatform; -using Windows.Win32.Security; - -namespace Agent.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(); - } -} - -public sealed unsafe class WfpDynamicSession : IDisposable -{ - private const FWPM_FILTER_FLAGS FwpmFilterFlagClearActionRight = (FWPM_FILTER_FLAGS)0x00000008; - private readonly FwpmEngineClose0SafeHandle _engine; - private Guid? _subLayerKey; - private readonly List<ulong> _filterIds = []; - - private WfpDynamicSession(FwpmEngineClose0SafeHandle engine) => _engine = engine; - public static WfpDynamicSession Open(string displayName = "Agent Sandbox MCP dynamic task session") - { - FWPM_SESSION0 session = default; - session.flags = PInvoke.FWPM_SESSION_FLAG_DYNAMIC; - uint result = PInvoke.FwpmEngineOpen0(10, null, session, out FwpmEngineClose0SafeHandle engine); - ThrowIfError(result, "FwpmEngineOpen0"); - return new(engine); - } - - public void ProbeWriteTransaction() - { - uint result = PInvoke.FwpmTransactionBegin0(_engine, 0); - ThrowIfError(result, "FwpmTransactionBegin0"); - result = PInvoke.FwpmTransactionAbort0(_engine); - ThrowIfError(result, "FwpmTransactionAbort0"); - } - - public IReadOnlyList<ulong> ApplyDefaultBlock(SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint>? allowedEndpoints = null) - { - if (_subLayerKey is not null) throw new InvalidOperationException("WFP policy is already applied in this session."); - byte[] sidBytes = new byte[appContainerSid.BinaryLength]; - appContainerSid.GetBinaryForm(sidBytes, 0); - uint result = PInvoke.FwpmTransactionBegin0(_engine, 0); - ThrowIfError(result, "FwpmTransactionBegin0"); - bool committed = false; - try - { - Guid subLayerKey = Guid.NewGuid(); - fixed (char* subLayerName = "Agent Sandbox MCP task sublayer") - { - FWPM_SUBLAYER0 subLayer = default; - subLayer.subLayerKey = subLayerKey; - subLayer.displayData.name = new PWSTR(subLayerName); - subLayer.weight = 0x7FFF; - ThrowIfError(PInvoke.FwpmSubLayerAdd0(_engine, in subLayer, default), "FwpmSubLayerAdd0"); - } - - fixed (byte* sidPointer = sidBytes) - { - foreach (NetworkEndpoint endpoint in allowedEndpoints ?? []) _filterIds.Add(AddPackagePermit(endpoint, subLayerKey, (SID*)sidPointer)); - _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv4 default block")); - _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv6 default block")); - } - ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0"); - committed = true; - _subLayerKey = subLayerKey; - return _filterIds.ToArray(); - } - finally - { - if (!committed) - { - _filterIds.Clear(); - _ = PInvoke.FwpmTransactionAbort0(_engine); - } - } - } - - public void RemovePolicy() - { - if (_subLayerKey is null) return; - ThrowIfError(PInvoke.FwpmTransactionBegin0(_engine, 0), "FwpmTransactionBegin0(remove)"); - bool committed = false; - try - { - foreach (ulong filterId in _filterIds.AsEnumerable().Reverse()) ThrowIfError(PInvoke.FwpmFilterDeleteById0(_engine, filterId), "FwpmFilterDeleteById0"); - Guid subLayerKey = _subLayerKey.Value; - ThrowIfError(PInvoke.FwpmSubLayerDeleteByKey0(_engine, in subLayerKey), "FwpmSubLayerDeleteByKey0"); - ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0(remove)"); - committed = true; - _filterIds.Clear(); - _subLayerKey = null; - } - finally - { - if (!committed) _ = PInvoke.FwpmTransactionAbort0(_engine); - } - } - - public void Dispose() - { - _engine.Dispose(); - } - - private ulong AddPackageBlock(Guid layerKey, Guid subLayerKey, SID* sid, string displayName) - { - FWPM_FILTER_CONDITION0 condition = default; - condition.fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID; - condition.matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; - condition.conditionValue.type = FWP_DATA_TYPE.FWP_SID; - condition.conditionValue.sid = sid; - fixed (char* name = displayName) - { - FWPM_FILTER0 filter = default; - filter.filterKey = Guid.NewGuid(); - filter.displayData.name = new PWSTR(name); - filter.layerKey = layerKey; - filter.subLayerKey = subLayerKey; - filter.weight.type = FWP_DATA_TYPE.FWP_UINT8; - filter.weight.uint8 = 8; - filter.numFilterConditions = 1; - filter.filterCondition = &condition; - filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_BLOCK; - ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0"); - return id; - } - } - - private ulong AddPackagePermit(NetworkEndpoint endpoint, Guid subLayerKey, SID* sid) - { - FWPM_FILTER_CONDITION0* conditions = stackalloc FWPM_FILTER_CONDITION0[3]; - conditions[0].fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID; - conditions[0].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; - conditions[0].conditionValue.type = FWP_DATA_TYPE.FWP_SID; - conditions[0].conditionValue.sid = sid; - conditions[1].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS; - conditions[1].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; - Guid layer; - FWP_BYTE_ARRAY16 ipv6 = default; - if (endpoint.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) - { - layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4; - conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_UINT32; - // FWP_UINT32 uses host-order numeric values. For 127.0.0.1 the - // numeric value is 0x7F000001; parse the network-order octets into - // that value rather than reinterpreting their in-memory byte order. - conditions[1].conditionValue.uint32 = BinaryPrimitives.ReadUInt32BigEndian(endpoint.Address.GetAddressBytes()); - } - else - { - layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6; - ipv6.byteArray16 = endpoint.Address.GetAddressBytes(); - conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_BYTE_ARRAY16_TYPE; - conditions[1].conditionValue.byteArray16 = &ipv6; - } - conditions[2].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT; - conditions[2].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; - conditions[2].conditionValue.type = FWP_DATA_TYPE.FWP_UINT16; - conditions[2].conditionValue.uint16 = endpoint.Port; - string displayName = $"Agent Sandbox MCP allow {endpoint.Address}:{endpoint.Port}"; - fixed (char* name = displayName) - { - FWPM_FILTER0 filter = default; - filter.filterKey = Guid.NewGuid(); - filter.displayData.name = new PWSTR(name); - filter.layerKey = layer; - filter.subLayerKey = subLayerKey; - filter.weight.type = FWP_DATA_TYPE.FWP_UINT8; - filter.weight.uint8 = 15; - filter.numFilterConditions = 3; - filter.filterCondition = conditions; - // A plain permit is a soft permit and can be overridden by a block - // from another sub-layer (for example Windows Firewall). The exact - // task allowance must be a hard permit; our higher-weight match in - // this sub-layer still prevents the task default-block from running. - filter.flags = FwpmFilterFlagClearActionRight; - filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_PERMIT; - ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0(permit)"); - return id; - } - } - - private static void ThrowIfError(uint result, string operation) - { - if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with WFP/Win32 error 0x{result:X8}."); - } -} diff --git a/src/Agent.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; } } } |
