summaryrefslogtreecommitdiff
path: root/src/Agent.Windows
diff options
context:
space:
mode:
Diffstat (limited to 'src/Agent.Windows')
-rw-r--r--src/Agent.Windows/Agent.Windows.csproj1
-rw-r--r--src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs64
-rw-r--r--src/Agent.Windows/NativeMethods.txt26
-rw-r--r--src/Agent.Windows/Network/LoopbackExemption.cs123
-rw-r--r--src/Agent.Windows/Network/NetworkAllowanceSnapshot.cs27
-rw-r--r--src/Agent.Windows/Network/TaskNetworkPolicy.cs83
-rw-r--r--src/Agent.Windows/Network/WfpDynamicSession.cs208
-rw-r--r--src/Agent.Windows/Recovery/RecoveryManager.cs52
-rw-r--r--src/Agent.Windows/Security/AppContainerProfile.cs8
-rw-r--r--src/Agent.Windows/Security/TaskAclManager.cs27
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs160
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; }
}
}