summaryrefslogtreecommitdiff
path: root/src/Agent.Windows
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-13 22:48:24 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-13 22:48:24 +0900
commit9337ac3e5edd42bb17f5e2ade6c2de02c7ef576d (patch)
tree9a48fefc88cc046f4ce1e2bc34fa0a47351e4f83 /src/Agent.Windows
parent36ae1a4cba791ec633a81b38b1d8345af8487276 (diff)
feat!: isolate privileged sandbox network operations per task
Run agent-mcp with an explicit asInvoker manifest and move WFP and loopback mutations into a fixed requireAdministrator helper under the release admin directory. Add the Agent.Admin client and host projects with task-scoped runas launch, a logon-SID and Administrators-only local named pipe, remote-client rejection, mutual PID validation, strict versioned JSON framing, fixed network-only operations, and bounded messages. Delay ACL and network Task Resource application until the first process_run. Verify executable identity, managed entry points, DNS snapshots, and normal-token WRITE_DAC access before elevation; reuse the helper only within the Task and keep elevation failures terminal without policy replanning. Keep LPAC creation, executable approval, Job confinement, and ACL leases in the normal process. Make startup platform checks read-only, create the fixed Application Profile during apply, preserve dynamic WFP lifetime in the helper, and route persistent loopback recovery through the same elevated boundary. Update recovery journaling, localized elevation and IPC errors, audit events, release publishing, manifests, README guidance, Specifications, Design, implementation records, and requirement traceability for the on-demand elevation model. Add administrative-boundary, file-only no-elevation, elevation-terminal, lazy-resource, crash-recovery, manifest, IPC schema, and operational verification coverage. Verified Release build with zero warnings, Core 105/105, Planner 10/10, administrative boundary 4/4, elevated Windows suite 79/79, and published stdio/HTTP MCP E2E.
Diffstat (limited to 'src/Agent.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; }
}
}