summaryrefslogtreecommitdiff
path: root/src/Agent.Windows
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
commitb07b41cd435991cd254957af79055080c85b346e (patch)
tree27720d3ce14e4d0b84670a55ac26a2f877ce3a20 /src/Agent.Windows
parent2fefd29e86c3b926d0cf4a362251d3f119688612 (diff)
feat: AI Agent Sandbox MCP Server v1を実装
Specifications、Design、ImplementationPlanの優先順位を整理し、単一Task実行モデル、typed option優先、設定可能なresource上限、local Ollamaを必須とするRelease Gateを文書化。 .NET 10/C# 14の5 production project、3 test project、E2E Clientを構築。typed YAML、en/ja locale、fixed/user prompt、stdioおよびloopback限定Streamable HTTP、agent_run/agent_status、Session、Ollama text/tool/Vision、TaskGraphとtoken accountingを実装。 Windows sandboxとしてfixed AppContainer、canonical/reparse検査、能力別ACL、path+SHA-256 executable approval、child-process restriction、Job Object、task journal、atomic file transaction、crash recoveryを実装。WFPのIPv4/IPv6 default denyとexact allow、dynamic session、temporary loopback exemption、管理者/BFE診断を追加。 Security/Recovery test、MCP/Ollama integration test、stdio/HTTP Operational E2E、Release Bundle生成・checksum・manifest・fresh extraction検査を追加。Requirement TraceabilityとPhase 0〜11の進捗、初回セットアップ可能なREADMEを整備。 Debug/Release/publish/objをrepository rootのbuild/へ集約し、旧project-local bin/objへの依存を除去。artifacts/とbuild/をGit管理外に設定し、clean checkoutでもsolution buildからE2E Clientまで生成可能にした。
Diffstat (limited to 'src/Agent.Windows')
-rw-r--r--src/Agent.Windows/Agent.Windows.csproj17
-rw-r--r--src/Agent.Windows/AssemblyMarker.cs3
-rw-r--r--src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs50
-rw-r--r--src/Agent.Windows/NativeMethods.txt46
-rw-r--r--src/Agent.Windows/Network/LoopbackExemption.cs123
-rw-r--r--src/Agent.Windows/Network/TaskNetworkPolicy.cs73
-rw-r--r--src/Agent.Windows/Network/WfpDynamicSession.cs208
-rw-r--r--src/Agent.Windows/Recovery/RecoveryManager.cs114
-rw-r--r--src/Agent.Windows/Recovery/TaskResourceJournal.cs108
-rw-r--r--src/Agent.Windows/Recovery/TransactionalFileEditor.cs141
-rw-r--r--src/Agent.Windows/Security/AppContainerProfile.cs41
-rw-r--r--src/Agent.Windows/Security/ExecutablePolicy.cs51
-rw-r--r--src/Agent.Windows/Security/PathPolicy.cs52
-rw-r--r--src/Agent.Windows/Security/RestrictedProcessLauncher.cs255
-rw-r--r--src/Agent.Windows/Security/SecurityAudit.cs29
-rw-r--r--src/Agent.Windows/Security/TaskAclManager.cs76
-rw-r--r--src/Agent.Windows/Security/UserOnlyDirectory.cs20
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs257
18 files changed, 1664 insertions, 0 deletions
diff --git a/src/Agent.Windows/Agent.Windows.csproj b/src/Agent.Windows/Agent.Windows.csproj
new file mode 100644
index 0000000..ff2f299
--- /dev/null
+++ b/src/Agent.Windows/Agent.Windows.csproj
@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+ <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
+ <CompilerGeneratedFilesOutputPath>$(IntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
+ </PropertyGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\Agent.Core\Agent.Core.csproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.Windows.CsWin32" PrivateAssets="all" />
+ </ItemGroup>
+ <ItemGroup>
+ <InternalsVisibleTo Include="Agent.Windows.Tests" />
+ </ItemGroup>
+</Project>
diff --git a/src/Agent.Windows/AssemblyMarker.cs b/src/Agent.Windows/AssemblyMarker.cs
new file mode 100644
index 0000000..7426ca0
--- /dev/null
+++ b/src/Agent.Windows/AssemblyMarker.cs
@@ -0,0 +1,3 @@
+namespace Agent.Windows;
+
+public static class AssemblyMarker;
diff --git a/src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs b/src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs
new file mode 100644
index 0000000..b8ca0ae
--- /dev/null
+++ b/src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs
@@ -0,0 +1,50 @@
+using System.ComponentModel;
+using System.Security.Principal;
+using Agent.Windows.Network;
+using Agent.Windows.Security;
+
+namespace Agent.Windows.Diagnostics;
+
+public sealed record WindowsPlatformCheckResult(
+ bool IsAdministrator,
+ bool BfeEngineOpen,
+ bool WfpWriteTransaction,
+ bool WfpFilterAddDelete,
+ int? ErrorCode,
+ string? ErrorOperation)
+{
+ public bool Success => IsAdministrator && BfeEngineOpen && WfpWriteTransaction && WfpFilterAddDelete;
+}
+
+public static class WindowsPlatformCheck
+{
+ public static WindowsPlatformCheckResult Run()
+ {
+ using WindowsIdentity identity = WindowsIdentity.GetCurrent();
+ bool administrator = new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
+ bool open = false;
+ bool transaction = false;
+ bool filters = 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);
+ }
+ catch (Exception ex) when (ex is UnauthorizedAccessException or InvalidOperationException or IOException)
+ {
+ return new(administrator, open, transaction, filters, ex.HResult, ex.Message);
+ }
+ }
+}
diff --git a/src/Agent.Windows/NativeMethods.txt b/src/Agent.Windows/NativeMethods.txt
new file mode 100644
index 0000000..471b47d
--- /dev/null
+++ b/src/Agent.Windows/NativeMethods.txt
@@ -0,0 +1,46 @@
+AssignProcessToJobObject
+CloseHandle
+CreateJobObject
+CreateProcess
+DeleteProcThreadAttributeList
+GetExitCodeProcess
+InitializeProcThreadAttributeList
+JOBOBJECT_EXTENDED_LIMIT_INFORMATION
+JOB_OBJECT_LIMIT
+PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY
+PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES
+PROC_THREAD_ATTRIBUTE_HANDLE_LIST
+ResumeThread
+SetHandleInformation
+SetInformationJobObject
+TerminateJobObject
+UpdateProcThreadAttribute
+STARTUPINFOEXW
+WaitForSingleObject
+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
new file mode 100644
index 0000000..a3c68ba
--- /dev/null
+++ b/src/Agent.Windows/Network/LoopbackExemption.cs
@@ -0,0 +1,123 @@
+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/TaskNetworkPolicy.cs b/src/Agent.Windows/Network/TaskNetworkPolicy.cs
new file mode 100644
index 0000000..59b2602
--- /dev/null
+++ b/src/Agent.Windows/Network/TaskNetworkPolicy.cs
@@ -0,0 +1,73 @@
+using System.Security.Principal;
+using Agent.Windows.Recovery;
+using Agent.Windows.Security;
+
+namespace Agent.Windows.Network;
+
+public sealed class TaskNetworkPolicy : IDisposable
+{
+ private readonly WfpDynamicSession _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 bool _disposed;
+
+ private TaskNetworkPolicy(WfpDynamicSession session, TaskResourceJournal journal, ISecurityAuditSink audit, Guid entryId,
+ SecurityIdentifier appContainerSid, Guid loopbackEntryId, bool loopbackAdded)
+ {
+ _session = session; _journal = journal; _audit = audit; _entryId = entryId;
+ _appContainerSid = appContainerSid; _loopbackEntryId = loopbackEntryId; _loopbackAdded = loopbackAdded;
+ }
+
+ public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> allowances, ISecurityAuditSink audit)
+ {
+ bool originallyExempt = LoopbackExemption.Contains(appContainerSid);
+ Guid loopbackEntryId = journal.Prepare("network.loopback_exemption", appContainerSid.Value,
+ new Dictionary<string, string> { ["originally_present"] = originallyExempt.ToString() });
+ 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
+ {
+ loopbackAdded = LoopbackExemption.Ensure(appContainerSid);
+ journal.MarkApplied(loopbackEntryId, 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) });
+ audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "wfp.apply", "applied", new Dictionary<string, string>
+ {
+ ["ipv4_ipv6_default_block"] = "true", ["filter_count"] = ids.Count.ToString(), ["session"] = "dynamic"
+ }));
+ audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", loopbackAdded ? "added" : "preserved",
+ new Dictionary<string, string> { ["app_container_sid"] = appContainerSid.Value }));
+ return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded);
+ }
+ catch
+ {
+ session.Dispose();
+ journal.MarkCleaned(entryId);
+ if (loopbackAdded) LoopbackExemption.Remove(appContainerSid);
+ journal.MarkCleaned(loopbackEntryId);
+ throw;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _session.RemovePolicy();
+ _session.Dispose();
+ _journal.MarkCleaned(_entryId);
+ if (_loopbackAdded) LoopbackExemption.Remove(_appContainerSid);
+ _journal.MarkCleaned(_loopbackEntryId);
+ _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "wfp.remove", "removed", new Dictionary<string, string> { ["residual_filters"] = "0" }));
+ _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed",
+ new Dictionary<string, string> { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" }));
+ _disposed = true;
+ }
+}
diff --git a/src/Agent.Windows/Network/WfpDynamicSession.cs b/src/Agent.Windows/Network/WfpDynamicSession.cs
new file mode 100644
index 0000000..ef8ba8e
--- /dev/null
+++ b/src/Agent.Windows/Network/WfpDynamicSession.cs
@@ -0,0 +1,208 @@
+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
new file mode 100644
index 0000000..df69a5e
--- /dev/null
+++ b/src/Agent.Windows/Recovery/RecoveryManager.cs
@@ -0,0 +1,114 @@
+using System.Security.AccessControl;
+using System.Security.Cryptography;
+using Agent.Windows.Security;
+using Agent.Windows.Network;
+
+namespace Agent.Windows.Recovery;
+
+public sealed record RecoveryResult(int JournalsRecovered, int ResourcesRecovered, IReadOnlyList<string> Errors)
+{
+ public bool IsClean => Errors.Count == 0;
+}
+
+public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink audit)
+{
+ public RecoveryResult RecoverAll()
+ {
+ 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))
+ {
+ journals++;
+ try
+ {
+ TaskResourceJournal journal = TaskResourceJournal.Open(path);
+ foreach (JournalEntry entry in journal.Entries.Reverse())
+ {
+ if (entry.State == JournalEntryState.Cleaned) continue;
+ if (entry.State == JournalEntryState.Applied) RecoverEntry(journal.TaskId, entry);
+ CleanupPrepared(entry);
+ journal.MarkCleaned(entry.Id);
+ resources++;
+ }
+ journal.CompleteAndDelete();
+ audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.complete", "clean", new Dictionary<string, string>()));
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException)
+ {
+ errors.Add($"{path}: {ex.Message}");
+ }
+ }
+ return new(journals, resources, errors);
+ }
+
+ private static void RecoverEntry(Guid taskId, JournalEntry entry)
+ {
+ switch (entry.Kind)
+ {
+ case "file.replace": RecoverFile(entry); break;
+ case "file.delete": RecoverDeletedFile(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;
+ default: throw new InvalidDataException($"Unknown journal resource kind '{entry.Kind}' for task '{taskId}'.");
+ }
+ }
+
+ private static void RecoverLoopback(JournalEntry entry)
+ {
+ if (!bool.Parse(entry.Data["originally_present"])) LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target));
+ }
+
+ private static void RecoverFile(JournalEntry entry)
+ {
+ string replacement = entry.Data["replacement_sha256"];
+ if (!File.Exists(entry.Target) || !Hash(entry.Target).Equals(replacement, StringComparison.Ordinal))
+ throw new InvalidOperationException($"File '{entry.Target}' changed after crash; automatic rollback refused.");
+ if (bool.Parse(entry.Data["existed"]))
+ {
+ byte[] original = entry.Data["backup_path"].Length > 0 ? File.ReadAllBytes(entry.Data["backup_path"]) : Convert.FromBase64String(entry.Data["original_base64"]);
+ if (!Convert.ToHexString(SHA256.HashData(original)).Equals(entry.Data["original_sha256"], StringComparison.Ordinal)) throw new InvalidDataException("Recovery backup hash mismatch.");
+ string temporary = entry.Target + ".recovery.tmp";
+ File.WriteAllBytes(temporary, original);
+ File.Move(temporary, entry.Target, overwrite: true);
+ }
+ else File.Delete(entry.Target);
+ }
+
+ private static void RecoverAcl(JournalEntry entry)
+ {
+ byte[] descriptor = Convert.FromBase64String(entry.Data["security_descriptor"]);
+ FileSystemSecurity security = Directory.Exists(entry.Target) ? new DirectorySecurity() : new FileSecurity();
+ security.SetSecurityDescriptorBinaryForm(descriptor, AccessControlSections.Access);
+ if (security is DirectorySecurity directory) FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(entry.Target), directory);
+ else FileSystemAclExtensions.SetAccessControl(new FileInfo(entry.Target), (FileSecurity)security);
+ }
+
+ private static void RecoverDeletedFile(JournalEntry entry)
+ {
+ if (File.Exists(entry.Target)) throw new InvalidOperationException($"Deleted path '{entry.Target}' was recreated after crash; automatic recovery refused.");
+ byte[] original = entry.Data["backup_path"].Length > 0 ? File.ReadAllBytes(entry.Data["backup_path"]) : Convert.FromBase64String(entry.Data["original_base64"]);
+ if (!Convert.ToHexString(SHA256.HashData(original)).Equals(entry.Data["original_sha256"], StringComparison.Ordinal)) throw new InvalidDataException("Delete recovery backup hash mismatch.");
+ File.WriteAllBytes(entry.Target, original);
+ }
+
+ private static void CleanupPrepared(JournalEntry entry)
+ {
+ if (entry.Kind == "network.loopback_exemption" && !bool.Parse(entry.Data["originally_present"]))
+ LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target));
+ 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)
+ {
+ File.Delete(backup);
+ string? parent = System.IO.Path.GetDirectoryName(backup);
+ if (parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent);
+ }
+ }
+ private static string Hash(string target)
+ {
+ using FileStream stream = File.OpenRead(target);
+ return Convert.ToHexString(SHA256.HashData(stream));
+ }
+}
diff --git a/src/Agent.Windows/Recovery/TaskResourceJournal.cs b/src/Agent.Windows/Recovery/TaskResourceJournal.cs
new file mode 100644
index 0000000..b638041
--- /dev/null
+++ b/src/Agent.Windows/Recovery/TaskResourceJournal.cs
@@ -0,0 +1,108 @@
+using System.Text.Json;
+
+namespace Agent.Windows.Recovery;
+
+public enum JournalEntryState { Prepared, Applied, Cleaned }
+
+public sealed class JournalEntry
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public string Kind { get; set; } = string.Empty;
+ public string Target { get; set; } = string.Empty;
+ public JournalEntryState State { get; set; }
+ public Dictionary<string, string> Data { get; set; } = new(StringComparer.Ordinal);
+}
+
+public sealed class JournalDocument
+{
+ public int SchemaVersion { get; set; } = 1;
+ public Guid TaskId { get; set; }
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public List<JournalEntry> Entries { get; set; } = [];
+}
+
+public sealed class TaskResourceJournal
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+ private readonly object _sync = new();
+ private readonly JournalDocument _document;
+
+ private TaskResourceJournal(string path, JournalDocument document) { Path = path; _document = document; }
+ public string Path { get; }
+ public Guid TaskId => _document.TaskId;
+ public IReadOnlyList<JournalEntry> Entries { get { lock (_sync) return _document.Entries.Select(Clone).ToArray(); } }
+
+ public static TaskResourceJournal Create(string journalDirectory, Guid taskId)
+ {
+ Directory.CreateDirectory(journalDirectory);
+ string path = System.IO.Path.Combine(journalDirectory, taskId.ToString("N") + ".journal.json");
+ if (File.Exists(path)) throw new InvalidOperationException($"Journal already exists for task '{taskId}'.");
+ var journal = new TaskResourceJournal(path, new JournalDocument { TaskId = taskId });
+ journal.Persist();
+ return journal;
+ }
+
+ public static TaskResourceJournal Open(string path)
+ {
+ JournalDocument document = JsonSerializer.Deserialize<JournalDocument>(File.ReadAllText(path), JsonOptions)
+ ?? throw new InvalidDataException($"Journal '{path}' is empty.");
+ if (document.SchemaVersion != 1 || document.TaskId == Guid.Empty) throw new InvalidDataException($"Journal '{path}' is invalid.");
+ return new(path, document);
+ }
+
+ public Guid Prepare(string kind, string target, IReadOnlyDictionary<string, string> data)
+ {
+ lock (_sync)
+ {
+ var entry = new JournalEntry { Kind = kind, Target = target, State = JournalEntryState.Prepared, Data = new(data, StringComparer.Ordinal) };
+ _document.Entries.Add(entry);
+ Persist();
+ return entry.Id;
+ }
+ }
+
+ public void MarkApplied(Guid entryId, IReadOnlyDictionary<string, string>? additionalData = null)
+ {
+ lock (_sync)
+ {
+ JournalEntry entry = Get(entryId);
+ if (entry.State != JournalEntryState.Prepared) throw new InvalidOperationException("Only prepared resources can be marked applied.");
+ if (additionalData is not null) foreach ((string key, string value) in additionalData) entry.Data[key] = value;
+ entry.State = JournalEntryState.Applied;
+ Persist();
+ }
+ }
+
+ public void MarkCleaned(Guid entryId)
+ {
+ lock (_sync)
+ {
+ JournalEntry entry = Get(entryId);
+ if (entry.State == JournalEntryState.Cleaned) return;
+ entry.State = JournalEntryState.Cleaned;
+ Persist();
+ }
+ }
+
+ public void CompleteAndDelete()
+ {
+ lock (_sync)
+ {
+ if (_document.Entries.Any(entry => entry.State != JournalEntryState.Cleaned)) throw new InvalidOperationException("Journal still contains live resources.");
+ File.Delete(Path);
+ }
+ }
+
+ private JournalEntry Get(Guid id) => _document.Entries.SingleOrDefault(entry => entry.Id == id) ?? throw new KeyNotFoundException($"Journal entry '{id}' not found.");
+ private void Persist()
+ {
+ string temporary = Path + ".tmp";
+ using (FileStream stream = new(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
+ {
+ JsonSerializer.Serialize(stream, _document, JsonOptions);
+ stream.Flush(flushToDisk: true);
+ }
+ File.Move(temporary, Path, overwrite: true);
+ }
+ private static JournalEntry Clone(JournalEntry source) => new() { Id = source.Id, Kind = source.Kind, Target = source.Target, State = source.State, Data = new(source.Data, StringComparer.Ordinal) };
+}
diff --git a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
new file mode 100644
index 0000000..52963a0
--- /dev/null
+++ b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
@@ -0,0 +1,141 @@
+using System.Security.Cryptography;
+using System.Text;
+using Agent.Windows.Security;
+
+namespace Agent.Windows.Recovery;
+
+public sealed class TransactionalFileEditor(TaskResourceJournal journal, string backupRoot, long memoryBackupLimitBytes = 16 * 1024 * 1024)
+{
+ public FileEditTransaction Replace(string path, string content, string? expectedSha256 = null) => Replace(path, Encoding.UTF8.GetBytes(content), expectedSha256);
+
+ public FileEditTransaction Replace(string path, ReadOnlySpan<byte> content, string? expectedSha256 = null)
+ {
+ string canonical = CanonicalPath.Resolve(path, requireExisting: false);
+ bool existed = File.Exists(canonical);
+ byte[]? original = existed ? File.ReadAllBytes(canonical) : null;
+ string originalHash = original is null ? string.Empty : Convert.ToHexString(SHA256.HashData(original));
+ if (expectedSha256 is not null && !originalHash.Equals(NormalizeHash(expectedSha256), StringComparison.Ordinal))
+ throw new SandboxPolicyException("file.concurrent_change", "File hash does not match expected_sha256.");
+
+ string backupPath = string.Empty;
+ string inline = string.Empty;
+ if (original is not null && original.LongLength > memoryBackupLimitBytes)
+ {
+ string directory = System.IO.Path.Combine(backupRoot, journal.TaskId.ToString("N"));
+ Directory.CreateDirectory(directory);
+ backupPath = System.IO.Path.Combine(directory, Guid.NewGuid().ToString("N") + ".backup");
+ File.WriteAllBytes(backupPath, original);
+ }
+ else if (original is not null) inline = Convert.ToBase64String(original);
+
+ string temporary = canonical + ".agent-mcp-" + Guid.NewGuid().ToString("N") + ".tmp";
+ var data = new Dictionary<string, string>
+ {
+ ["existed"] = existed.ToString(), ["original_sha256"] = originalHash, ["original_base64"] = inline,
+ ["backup_path"] = backupPath, ["temporary_path"] = temporary
+ };
+ Guid entryId = journal.Prepare("file.replace", canonical, data);
+ File.WriteAllBytes(temporary, content);
+ string replacementHash = Convert.ToHexString(SHA256.HashData(content));
+ File.Move(temporary, canonical, overwrite: true);
+ journal.MarkApplied(entryId, new Dictionary<string, string> { ["replacement_sha256"] = replacementHash });
+ return new(journal, entryId, canonical, originalHash, replacementHash, backupPath, inline, existed);
+ }
+
+ public FileDeleteTransaction Delete(string path, string? expectedSha256 = null)
+ {
+ string canonical = CanonicalPath.Resolve(path);
+ byte[] original = File.ReadAllBytes(canonical);
+ string originalHash = Convert.ToHexString(SHA256.HashData(original));
+ if (expectedSha256 is not null && !originalHash.Equals(NormalizeHash(expectedSha256), StringComparison.Ordinal))
+ throw new SandboxPolicyException("file.concurrent_change", "File hash does not match expected_sha256.");
+ string backupPath = string.Empty;
+ string inline = string.Empty;
+ if (original.LongLength > memoryBackupLimitBytes)
+ {
+ string directory = System.IO.Path.Combine(backupRoot, journal.TaskId.ToString("N"));
+ Directory.CreateDirectory(directory);
+ backupPath = System.IO.Path.Combine(directory, Guid.NewGuid().ToString("N") + ".backup");
+ File.WriteAllBytes(backupPath, original);
+ }
+ else inline = Convert.ToBase64String(original);
+ Guid entryId = journal.Prepare("file.delete", canonical, new Dictionary<string, string>
+ {
+ ["original_sha256"] = originalHash, ["original_base64"] = inline, ["backup_path"] = backupPath
+ });
+ File.Delete(canonical);
+ journal.MarkApplied(entryId);
+ return new(journal, entryId, canonical, originalHash, backupPath, inline);
+ }
+
+ private static string NormalizeHash(string hash) => hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant();
+}
+
+public sealed class FileDeleteTransaction(TaskResourceJournal journal, Guid entryId, string path, string originalHash, string backupPath, string originalBase64)
+{
+ private bool _finished;
+ public void Commit() { if (_finished) return; Cleanup(); journal.MarkCleaned(entryId); _finished = true; }
+ public void Rollback()
+ {
+ if (_finished) return;
+ if (File.Exists(path)) throw new InvalidOperationException("Rollback refused because the deleted path was recreated.");
+ byte[] original = backupPath.Length > 0 ? File.ReadAllBytes(backupPath) : Convert.FromBase64String(originalBase64);
+ if (!Convert.ToHexString(SHA256.HashData(original)).Equals(originalHash, StringComparison.Ordinal)) throw new InvalidDataException("Delete backup hash verification failed.");
+ File.WriteAllBytes(path, original);
+ Cleanup(); journal.MarkCleaned(entryId); _finished = true;
+ }
+ private void Cleanup()
+ {
+ if (backupPath.Length == 0) return;
+ File.Delete(backupPath);
+ string? parent = System.IO.Path.GetDirectoryName(backupPath);
+ if (parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent);
+ }
+}
+
+public sealed class FileEditTransaction(
+ TaskResourceJournal journal, Guid entryId, string path, string originalHash, string replacementHash,
+ string backupPath, string originalBase64, bool originallyExisted)
+{
+ private bool _finished;
+
+ public void Commit()
+ {
+ if (_finished) return;
+ DeleteBackup();
+ journal.MarkCleaned(entryId);
+ _finished = true;
+ }
+
+ public void Rollback()
+ {
+ if (_finished) return;
+ if (!File.Exists(path) || !Hash(path).Equals(replacementHash, StringComparison.Ordinal))
+ throw new InvalidOperationException("Rollback refused because the replacement file changed after the transaction.");
+ if (originallyExisted)
+ {
+ byte[] original = backupPath.Length > 0 ? File.ReadAllBytes(backupPath) : Convert.FromBase64String(originalBase64);
+ if (!Convert.ToHexString(SHA256.HashData(original)).Equals(originalHash, StringComparison.Ordinal)) throw new InvalidDataException("Backup hash verification failed.");
+ string temporary = path + ".rollback-" + Guid.NewGuid().ToString("N") + ".tmp";
+ File.WriteAllBytes(temporary, original);
+ File.Move(temporary, path, overwrite: true);
+ }
+ else File.Delete(path);
+ DeleteBackup();
+ journal.MarkCleaned(entryId);
+ _finished = true;
+ }
+
+ private void DeleteBackup()
+ {
+ if (backupPath.Length == 0) return;
+ File.Delete(backupPath);
+ string? parent = System.IO.Path.GetDirectoryName(backupPath);
+ if (parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent);
+ }
+ private static string Hash(string target)
+ {
+ using FileStream stream = File.OpenRead(target);
+ return Convert.ToHexString(SHA256.HashData(stream));
+ }
+}
diff --git a/src/Agent.Windows/Security/AppContainerProfile.cs b/src/Agent.Windows/Security/AppContainerProfile.cs
new file mode 100644
index 0000000..47aa64e
--- /dev/null
+++ b/src/Agent.Windows/Security/AppContainerProfile.cs
@@ -0,0 +1,41 @@
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+
+namespace Agent.Windows.Security;
+
+public sealed partial class AppContainerProfile
+{
+ public const string FixedName = "AgentSandboxMcp.V1";
+ private const int AlreadyExists = unchecked((int)0x800700B7);
+
+ private AppContainerProfile(string name, SecurityIdentifier sid) { Name = name; Sid = sid; }
+ public string Name { get; }
+ public SecurityIdentifier Sid { get; }
+
+ public static AppContainerProfile EnsureFixed()
+ {
+ int result = Native.CreateAppContainerProfile(FixedName, "Agent Sandbox MCP v1", "Fixed sandbox profile for Agent Sandbox MCP", 0, 0, out nint sidPointer);
+ if (result == AlreadyExists) result = Native.DeriveAppContainerSidFromAppContainerName(FixedName, out 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)]
+ internal static partial int CreateAppContainerProfile(string name, string displayName, string description, nint capabilities, uint capabilityCount, out nint sid);
+
+ [LibraryImport("userenv.dll", StringMarshalling = StringMarshalling.Utf16)]
+ internal static partial int DeriveAppContainerSidFromAppContainerName(string name, out nint sid);
+
+ [LibraryImport("advapi32.dll")]
+ internal static partial nint FreeSid(nint sid);
+ }
+}
diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs
new file mode 100644
index 0000000..3d20bfa
--- /dev/null
+++ b/src/Agent.Windows/Security/ExecutablePolicy.cs
@@ -0,0 +1,51 @@
+using System.Security.Cryptography;
+
+namespace Agent.Windows.Security;
+
+public sealed record ExecutableApproval(string Path, string Sha256);
+public sealed record VerifiedExecutable(string CanonicalPath, string Sha256);
+
+public sealed class ExecutablePolicy(IEnumerable<ExecutableApproval> approvals, bool allowShell)
+{
+ private readonly IReadOnlyDictionary<string, string> _approvals = approvals.ToDictionary(
+ approval => CanonicalPath.Resolve(approval.Path), approval => NormalizeHash(approval.Sha256), StringComparer.OrdinalIgnoreCase);
+
+ public VerifiedExecutable Verify(string executable)
+ {
+ string canonical = CanonicalPath.Resolve(executable);
+ if (!allowShell && IsShell(canonical)) throw new SandboxPolicyException("process.shell_denied", "Shell execution is disabled.");
+ if (!_approvals.TryGetValue(canonical, out string? approvedHash)) throw new SandboxPolicyException("process.path_denied", "Executable path is not approved.");
+ using FileStream stream = new(canonical, FileMode.Open, FileAccess.Read, FileShare.Read);
+ string actualHash = Convert.ToHexString(SHA256.HashData(stream));
+ if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(approvedHash), Convert.FromHexString(actualHash)))
+ throw new SandboxPolicyException("process.hash_denied", "Executable SHA-256 does not match its approval.");
+ return new(canonical, actualHash);
+ }
+
+ public static string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments)
+ {
+ if (!Path.GetFileName(executable).Equals("dotnet.exe", StringComparison.OrdinalIgnoreCase) || arguments.Count == 0) return null;
+ if (arguments[0].Equals("exec", StringComparison.OrdinalIgnoreCase))
+ {
+ if (arguments.Count < 2 || arguments[1].StartsWith("-", StringComparison.Ordinal))
+ throw new SandboxPolicyException("process.managed_entry_invalid", "dotnet exec requires an explicit managed entry assembly as its next argument.");
+ return arguments[1];
+ }
+ return arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? arguments[0] : null;
+ }
+
+ public static string ComputeSha256(string executable)
+ {
+ using FileStream stream = new(CanonicalPath.Resolve(executable), FileMode.Open, FileAccess.Read, FileShare.Read);
+ return Convert.ToHexString(SHA256.HashData(stream));
+ }
+
+ private static bool IsShell(string path) => Path.GetFileName(path) is string name &&
+ (name.Equals("cmd.exe", StringComparison.OrdinalIgnoreCase) || name.Equals("powershell.exe", StringComparison.OrdinalIgnoreCase) || name.Equals("pwsh.exe", StringComparison.OrdinalIgnoreCase));
+ private static string NormalizeHash(string hash)
+ {
+ string normalized = hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant();
+ if (normalized.Length != 64 || !normalized.All(Uri.IsHexDigit)) throw new ArgumentException("SHA-256 approval must contain 64 hexadecimal characters.", nameof(hash));
+ return normalized;
+ }
+}
diff --git a/src/Agent.Windows/Security/PathPolicy.cs b/src/Agent.Windows/Security/PathPolicy.cs
new file mode 100644
index 0000000..32a949c
--- /dev/null
+++ b/src/Agent.Windows/Security/PathPolicy.cs
@@ -0,0 +1,52 @@
+namespace Agent.Windows.Security;
+
+[Flags]
+public enum FileCapability { None = 0, Read = 1, Write = 2, Execute = 4, Delete = 8 }
+
+public sealed record FilePolicyEntry(string Path, FileCapability Capabilities);
+
+public sealed class PathPolicy(IEnumerable<FilePolicyEntry> entries)
+{
+ private readonly IReadOnlyList<FilePolicyEntry> _entries = entries.Select(entry => entry with { Path = CanonicalPath.Resolve(entry.Path, requireExisting: false) }).ToArray();
+
+ public bool IsAllowed(string path, FileCapability required)
+ {
+ string canonical = CanonicalPath.Resolve(path, requireExisting: required is FileCapability.Read or FileCapability.Execute or FileCapability.Delete);
+ return _entries.Any(entry => IsAtOrBelow(canonical, entry.Path) && (entry.Capabilities & required) == required);
+ }
+
+ public void Demand(string path, FileCapability required)
+ {
+ if (!IsAllowed(path, required)) throw new SandboxPolicyException("policy.deny", $"'{path}' is not approved for {required}.");
+ }
+
+ private static bool IsAtOrBelow(string candidate, string root) =>
+ candidate.Equals(root, StringComparison.OrdinalIgnoreCase) ||
+ candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
+}
+
+public static class CanonicalPath
+{
+ public static string Resolve(string path, bool requireExisting = true)
+ {
+ if (string.IsNullOrWhiteSpace(path)) throw new SandboxPolicyException("path.invalid", "Path is required.");
+ string fullPath = Path.GetFullPath(path);
+ string? root = Path.GetPathRoot(fullPath);
+ if (string.IsNullOrEmpty(root)) throw new SandboxPolicyException("path.invalid", "Path has no filesystem root.");
+ string current = root;
+ foreach (string segment in fullPath[root.Length..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries))
+ {
+ current = Path.Combine(current, segment);
+ if (!File.Exists(current) && !Directory.Exists(current)) continue;
+ if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
+ throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: '{current}'.");
+ }
+ if (requireExisting && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Approved path does not exist.", fullPath);
+ return Path.TrimEndingDirectorySeparator(fullPath);
+ }
+}
+
+public sealed class SandboxPolicyException(string code, string message) : UnauthorizedAccessException(message)
+{
+ public string Code { get; } = code;
+}
diff --git a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
new file mode 100644
index 0000000..0786150
--- /dev/null
+++ b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
@@ -0,0 +1,255 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+using System.Text;
+using Microsoft.Win32.SafeHandles;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+using Windows.Win32.Security;
+using Windows.Win32.System.JobObjects;
+using Windows.Win32.System.Threading;
+
+namespace Agent.Windows.Security;
+
+public sealed record RestrictedProcessResult(uint ProcessId, uint ExitCode, string StandardOutput, string StandardError);
+public sealed record SandboxJobLimits(uint MaxProcesses = 16, ulong MaxJobMemoryBytes = 4UL * 1024 * 1024 * 1024);
+
+public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executablePolicy, ISecurityAuditSink audit, SecurityIdentifier? appContainerSid = null, SandboxJobLimits? jobLimits = null)
+{
+ private const uint ProcessCreationChildProcessRestricted = 0x1;
+ private readonly SandboxJobLimits _jobLimits = jobLimits ?? new();
+
+ public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout, string? standardInput = null)
+ {
+ VerifiedExecutable verified;
+ VerifiedExecutable? managedEntry = null;
+ try
+ {
+ verified = executablePolicy.Verify(executable);
+ string? managedEntryPath = ExecutablePolicy.ManagedEntryPoint(verified.CanonicalPath, arguments);
+ if (managedEntryPath is not null) managedEntry = executablePolicy.Verify(managedEntryPath);
+ }
+ catch (SandboxPolicyException ex)
+ {
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string> { ["code"] = ex.Code, ["executable"] = executable }));
+ throw;
+ }
+
+ string cwd = CanonicalPath.Resolve(workingDirectory);
+ using FileStream stdin = CaptureFile();
+ using FileStream stdout = CaptureFile();
+ using FileStream stderr = CaptureFile();
+ if (!string.IsNullOrEmpty(standardInput))
+ {
+ byte[] input = Encoding.UTF8.GetBytes(standardInput);
+ stdin.Write(input);
+ stdin.Flush(true);
+ }
+ stdin.Position = 0;
+ MakeInheritable(stdin.SafeFileHandle);
+ MakeInheritable(stdout.SafeFileHandle);
+ MakeInheritable(stderr.SafeFileHandle);
+ using SafeFileHandle job = PInvoke.CreateJobObject(null, null);
+ if (job.IsInvalid) throw LastError("CreateJobObject");
+ try
+ {
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = default;
+ limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE |
+ JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_JOB_MEMORY;
+ limits.BasicLimitInformation.ActiveProcessLimit = _jobLimits.MaxProcesses;
+ limits.JobMemoryLimit = (nuint)_jobLimits.MaxJobMemoryBytes;
+ if (!PInvoke.SetInformationJobObject(new HANDLE(job.DangerousGetHandle()), JOBOBJECTINFOCLASS.JobObjectExtendedLimitInformation, &limits, (uint)sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)))
+ throw LastError("SetInformationJobObject");
+
+ nuint attributeSize = 0;
+ uint attributeCount = appContainerSid is null ? 2u : 3u;
+ _ = PInvoke.InitializeProcThreadAttributeList(default, attributeCount, 0, &attributeSize);
+ LPPROC_THREAD_ATTRIBUTE_LIST attributes = (LPPROC_THREAD_ATTRIBUTE_LIST)NativeMemory.Alloc(attributeSize);
+ void* sidBuffer = null;
+ void* internetClientSidBuffer = null;
+ void* privateNetworkSidBuffer = null;
+ try
+ {
+ if (!PInvoke.InitializeProcThreadAttributeList(attributes, attributeCount, 0, &attributeSize)) throw LastError("InitializeProcThreadAttributeList");
+ uint childPolicy = ProcessCreationChildProcessRestricted;
+ if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, &childPolicy, sizeof(uint), null, null))
+ throw LastError("UpdateProcThreadAttribute");
+ HANDLE[] inheritedHandles =
+ [
+ new(stdin.SafeFileHandle.DangerousGetHandle()),
+ new(stdout.SafeFileHandle.DangerousGetHandle()),
+ new(stderr.SafeFileHandle.DangerousGetHandle())
+ ];
+ fixed (HANDLE* inheritedHandlePointer = inheritedHandles)
+ {
+ if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
+ inheritedHandlePointer, (nuint)(sizeof(HANDLE) * inheritedHandles.Length), null, null))
+ throw LastError("UpdateProcThreadAttribute(HandleList)");
+ }
+ SecurityCapabilities securityCapabilities = default;
+ if (appContainerSid is not null)
+ {
+ byte[] sidBytes = new byte[appContainerSid.BinaryLength];
+ appContainerSid.GetBinaryForm(sidBytes, 0);
+ sidBuffer = NativeMemory.Alloc((nuint)sidBytes.Length);
+ Marshal.Copy(sidBytes, 0, (nint)sidBuffer, sidBytes.Length);
+ securityCapabilities.AppContainerSid = (nint)sidBuffer;
+ internetClientSidBuffer = AllocateSid(new SecurityIdentifier("S-1-15-3-1"));
+ privateNetworkSidBuffer = AllocateSid(new SecurityIdentifier("S-1-15-3-3"));
+ SID_AND_ATTRIBUTES* capabilities = stackalloc SID_AND_ATTRIBUTES[2];
+ capabilities[0].Sid = new PSID(internetClientSidBuffer);
+ capabilities[0].Attributes = 0x00000004;
+ capabilities[1].Sid = new PSID(privateNetworkSidBuffer);
+ capabilities[1].Attributes = 0x00000004;
+ securityCapabilities.Capabilities = (nint)capabilities;
+ securityCapabilities.CapabilityCount = 2;
+ if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES,
+ &securityCapabilities, (nuint)sizeof(SecurityCapabilities), null, null)) throw LastError("UpdateProcThreadAttribute(SecurityCapabilities)");
+ }
+ STARTUPINFOEXW startup = default;
+ startup.StartupInfo.cb = (uint)sizeof(STARTUPINFOEXW);
+ startup.StartupInfo.dwFlags = STARTUPINFOW_FLAGS.STARTF_USESTDHANDLES;
+ startup.StartupInfo.hStdInput = inheritedHandles[0];
+ startup.StartupInfo.hStdOutput = inheritedHandles[1];
+ startup.StartupInfo.hStdError = inheritedHandles[2];
+ startup.lpAttributeList = attributes;
+ PROCESS_INFORMATION process = default;
+ string commandLine = BuildCommandLine(verified.CanonicalPath, arguments);
+ Span<char> commandBuffer = new char[commandLine.Length + 1];
+ commandLine.AsSpan().CopyTo(commandBuffer);
+ if (!PInvoke.CreateProcess(null, ref commandBuffer, null, null, true,
+ PROCESS_CREATION_FLAGS.EXTENDED_STARTUPINFO_PRESENT | PROCESS_CREATION_FLAGS.CREATE_SUSPENDED,
+ null, cwd, in startup.StartupInfo, out process)) throw LastError("CreateProcess");
+ try
+ {
+ using var processView = new SafeFileHandle(process.hProcess, ownsHandle: false);
+ if (!PInvoke.AssignProcessToJobObject(job, processView)) throw LastError("AssignProcessToJobObject");
+ try
+ {
+ VerifiedExecutable postCreate = executablePolicy.Verify(executable);
+ if (!string.Equals(postCreate.CanonicalPath, verified.CanonicalPath, StringComparison.OrdinalIgnoreCase) ||
+ !string.Equals(postCreate.Sha256, verified.Sha256, StringComparison.Ordinal))
+ throw new SandboxPolicyException("process.identity_changed", "Executable identity changed during process creation.");
+ if (managedEntry is not null)
+ {
+ VerifiedExecutable postCreateManaged = executablePolicy.Verify(managedEntry.CanonicalPath);
+ if (!string.Equals(postCreateManaged.Sha256, managedEntry.Sha256, StringComparison.Ordinal))
+ throw new SandboxPolicyException("process.managed_identity_changed", "Managed entry assembly identity changed during process creation.");
+ }
+ }
+ catch (SandboxPolicyException ex)
+ {
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string>
+ {
+ ["code"] = ex.Code, ["executable"] = executable, ["stage"] = "post_create_pre_resume"
+ }));
+ throw;
+ }
+ if (PInvoke.ResumeThread(process.hThread) == uint.MaxValue) throw LastError("ResumeThread");
+ var processAudit = new Dictionary<string, string>
+ {
+ ["path"] = verified.CanonicalPath, ["sha256"] = verified.Sha256, ["child_process_policy"] = "restricted",
+ ["app_container_sid"] = appContainerSid?.Value ?? "none", ["network_capabilities"] = appContainerSid is null ? "none" : "internetClient,privateNetworkClientServer",
+ ["pid"] = process.dwProcessId.ToString()
+ };
+ if (managedEntry is not null)
+ {
+ processAudit["managed_entry_path"] = managedEntry.CanonicalPath;
+ processAudit["managed_entry_sha256"] = managedEntry.Sha256;
+ }
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit));
+ WAIT_EVENT wait = PInvoke.WaitForSingleObject(process.hProcess, checked((uint)Math.Clamp(timeout.TotalMilliseconds, 1, uint.MaxValue - 1)));
+ if (wait == WAIT_EVENT.WAIT_TIMEOUT)
+ {
+ _ = PInvoke.TerminateJobObject(job, 1460);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.timeout", "terminated", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString() }));
+ throw new TimeoutException("Sandbox process exceeded its timeout.");
+ }
+ if (!PInvoke.GetExitCodeProcess(processView, out uint exitCode)) throw LastError("GetExitCodeProcess");
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.exit", "observed", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() }));
+ if (exitCode != 0)
+ {
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "observed", new Dictionary<string, string>
+ {
+ ["code"] = "restricted_process_failure", ["pid"] = process.dwProcessId.ToString(),
+ ["child_process_policy"] = "restricted", ["exit_code"] = exitCode.ToString()
+ }));
+ }
+ return new(process.dwProcessId, exitCode, ReadCapture(stdout), ReadCapture(stderr));
+ }
+ finally
+ {
+ PInvoke.CloseHandle(process.hThread);
+ PInvoke.CloseHandle(process.hProcess);
+ }
+ }
+ finally
+ {
+ if (attributes.Value is not null) PInvoke.DeleteProcThreadAttributeList(attributes);
+ NativeMemory.Free(sidBuffer);
+ NativeMemory.Free(internetClientSidBuffer);
+ NativeMemory.Free(privateNetworkSidBuffer);
+ NativeMemory.Free(attributes.Value);
+ }
+ }
+ finally { }
+ }
+
+ private static Win32Exception LastError(string operation)
+ {
+ int code = Marshal.GetLastWin32Error();
+ return new(code, $"{operation} failed with Win32 error {code}.");
+ }
+ private static FileStream CaptureFile()
+ {
+ string path = Path.Combine(Path.GetTempPath(), "agent-mcp-process-" + Guid.NewGuid().ToString("N") + ".tmp");
+ return new FileStream(path, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete, 4096,
+ FileOptions.DeleteOnClose | FileOptions.SequentialScan);
+ }
+ private static void* AllocateSid(SecurityIdentifier sid)
+ {
+ byte[] bytes = new byte[sid.BinaryLength];
+ sid.GetBinaryForm(bytes, 0);
+ void* buffer = NativeMemory.Alloc((nuint)bytes.Length);
+ Marshal.Copy(bytes, 0, (nint)buffer, bytes.Length);
+ return buffer;
+ }
+ private static void MakeInheritable(SafeFileHandle handle)
+ {
+ if (!PInvoke.SetHandleInformation(handle, (uint)HANDLE_FLAGS.HANDLE_FLAG_INHERIT, HANDLE_FLAGS.HANDLE_FLAG_INHERIT))
+ throw LastError("SetHandleInformation");
+ }
+ private static string ReadCapture(FileStream stream)
+ {
+ const int maxCharacters = 1024 * 1024;
+ stream.Position = 0;
+ using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
+ char[] buffer = new char[maxCharacters + 1];
+ int count = reader.ReadBlock(buffer, 0, buffer.Length);
+ return count > maxCharacters ? new string(buffer, 0, maxCharacters) + "\n[output truncated]" : new string(buffer, 0, count);
+ }
+ private static string BuildCommandLine(string executable, IReadOnlyList<string> arguments) => string.Join(' ', [Quote(executable), .. arguments.Select(Quote)]);
+ private static string Quote(string value)
+ {
+ if (value.Length > 0 && !value.Any(static character => char.IsWhiteSpace(character) || character == '"')) return value;
+ var result = new System.Text.StringBuilder().Append('"');
+ int slashes = 0;
+ foreach (char character in value)
+ {
+ if (character == '\\') { slashes++; continue; }
+ if (character == '"') result.Append('\\', (slashes * 2) + 1).Append('"');
+ else result.Append('\\', slashes).Append(character);
+ slashes = 0;
+ }
+ return result.Append('\\', slashes * 2).Append('"').ToString();
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct SecurityCapabilities
+ {
+ public nint AppContainerSid;
+ public nint Capabilities;
+ public uint CapabilityCount;
+ public uint Reserved;
+ }
+}
diff --git a/src/Agent.Windows/Security/SecurityAudit.cs b/src/Agent.Windows/Security/SecurityAudit.cs
new file mode 100644
index 0000000..8858c2b
--- /dev/null
+++ b/src/Agent.Windows/Security/SecurityAudit.cs
@@ -0,0 +1,29 @@
+using System.Text.Json;
+
+namespace Agent.Windows.Security;
+
+public sealed record SecurityAuditEvent(DateTimeOffset Timestamp, Guid TaskId, string Event, string Outcome, IReadOnlyDictionary<string, string> Data);
+
+public interface ISecurityAuditSink
+{
+ void Write(SecurityAuditEvent auditEvent);
+}
+
+public sealed class JsonLinesSecurityAuditSink(string path) : ISecurityAuditSink
+{
+ private readonly object _sync = new();
+ public void Write(SecurityAuditEvent auditEvent)
+ {
+ string? directory = Path.GetDirectoryName(Path.GetFullPath(path));
+ if (directory is not null) Directory.CreateDirectory(directory);
+ string line = JsonSerializer.Serialize(auditEvent);
+ lock (_sync) File.AppendAllText(path, line + Environment.NewLine);
+ }
+}
+
+public sealed class MemorySecurityAuditSink : ISecurityAuditSink
+{
+ private readonly List<SecurityAuditEvent> _events = [];
+ public IReadOnlyList<SecurityAuditEvent> Events => _events;
+ public void Write(SecurityAuditEvent auditEvent) => _events.Add(auditEvent);
+}
diff --git a/src/Agent.Windows/Security/TaskAclManager.cs b/src/Agent.Windows/Security/TaskAclManager.cs
new file mode 100644
index 0000000..f01e859
--- /dev/null
+++ b/src/Agent.Windows/Security/TaskAclManager.cs
@@ -0,0 +1,76 @@
+using System.Security.AccessControl;
+using System.Security.Principal;
+using Agent.Windows.Recovery;
+
+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 AclSnapshot Grant(Guid taskId, string path, FileCapability capabilities)
+ {
+ string canonical = CanonicalPath.Resolve(path);
+ FileSystemSecurity security = ReadSecurity(canonical);
+ byte[] original = security.GetSecurityDescriptorBinaryForm();
+ Guid? journalEntry = journal?.Prepare("acl", canonical, new Dictionary<string, string>
+ {
+ ["security_descriptor"] = Convert.ToBase64String(original)
+ });
+ FileSystemRights rights = ToRights(capabilities);
+ if (rights == 0) throw new ArgumentOutOfRangeException(nameof(capabilities));
+ bool directory = Directory.Exists(canonical);
+ var rule = new FileSystemAccessRule(appContainerSid, rights,
+ directory ? InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit : InheritanceFlags.None,
+ PropagationFlags.None, AccessControlType.Allow);
+ security.AddAccessRule(rule);
+ WriteSecurity(canonical, security);
+ if (journalEntry is not null) journal!.MarkApplied(journalEntry.Value);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.apply", "applied", new Dictionary<string, string> { ["path"] = canonical, ["sid"] = appContainerSid.Value, ["rights"] = rights.ToString() }));
+ return new(canonical, original, journalEntry);
+ }
+
+ public void Restore(Guid taskId, AclSnapshot snapshot)
+ {
+ FileSystemSecurity security = Directory.Exists(snapshot.Path) ? new DirectorySecurity() : new FileSecurity();
+ security.SetSecurityDescriptorBinaryForm(snapshot.SecurityDescriptor, AccessControlSections.Access);
+ WriteSecurity(snapshot.Path, security);
+ if (snapshot.JournalEntryId is not null) journal!.MarkCleaned(snapshot.JournalEntryId.Value);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.restore", "restored", new Dictionary<string, string> { ["path"] = snapshot.Path }));
+ }
+
+ public void NormalizeNewEntry(Guid taskId, string path)
+ {
+ string canonical = CanonicalPath.Resolve(path);
+ FileSystemSecurity security = ReadSecurity(canonical);
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+ foreach (FileSystemAccessRule rule in security.GetAccessRules(includeExplicit: true, includeInherited: false, typeof(SecurityIdentifier))
+ .OfType<FileSystemAccessRule>()
+ .Where(rule => rule.IdentityReference == appContainerSid)
+ .ToArray())
+ security.RemoveAccessRuleSpecific(rule);
+ security.SetAccessRuleProtection(isProtected: false, preserveInheritance: false);
+ WriteSecurity(canonical, security);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.normalize", "normalized", new Dictionary<string, string> { ["path"] = canonical }));
+ }
+
+ private static FileSystemSecurity ReadSecurity(string path) => Directory.Exists(path)
+ ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access)
+ : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access);
+
+ private static void WriteSecurity(string path, FileSystemSecurity security)
+ {
+ if (security is DirectorySecurity directorySecurity) FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(path), directorySecurity);
+ else FileSystemAclExtensions.SetAccessControl(new FileInfo(path), (FileSecurity)security);
+ }
+
+ private static FileSystemRights ToRights(FileCapability capabilities)
+ {
+ FileSystemRights rights = 0;
+ if ((capabilities & FileCapability.Read) != 0) rights |= FileSystemRights.ReadAndExecute | FileSystemRights.ListDirectory;
+ if ((capabilities & FileCapability.Write) != 0) rights |= FileSystemRights.Write;
+ if ((capabilities & FileCapability.Execute) != 0) rights |= FileSystemRights.ReadAndExecute;
+ if ((capabilities & FileCapability.Delete) != 0) rights |= FileSystemRights.Delete | FileSystemRights.DeleteSubdirectoriesAndFiles;
+ return rights;
+ }
+}
diff --git a/src/Agent.Windows/Security/UserOnlyDirectory.cs b/src/Agent.Windows/Security/UserOnlyDirectory.cs
new file mode 100644
index 0000000..a9ae5ae
--- /dev/null
+++ b/src/Agent.Windows/Security/UserOnlyDirectory.cs
@@ -0,0 +1,20 @@
+using System.Security.AccessControl;
+using System.Security.Principal;
+
+namespace Agent.Windows.Security;
+
+public static class UserOnlyDirectory
+{
+ public static void Ensure(string path)
+ {
+ Directory.CreateDirectory(path);
+ using WindowsIdentity identity = WindowsIdentity.GetCurrent();
+ SecurityIdentifier user = identity.User ?? throw new InvalidOperationException("Current Windows identity has no user SID.");
+ var security = new DirectorySecurity();
+ security.SetOwner(user);
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+ security.AddAccessRule(new FileSystemAccessRule(user, FileSystemRights.FullControl,
+ InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
+ FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(path), security);
+ }
+}
diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs
new file mode 100644
index 0000000..a21544b
--- /dev/null
+++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs
@@ -0,0 +1,257 @@
+using System.Collections.Concurrent;
+using System.IO.Enumeration;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Agent.Core.Tools;
+using Agent.Windows.Network;
+using Agent.Windows.Recovery;
+using Agent.Windows.Security;
+
+namespace Agent.Windows.Tools;
+
+public sealed record WindowsSandboxSettings(
+ IReadOnlyList<FilePolicyEntry> Files,
+ IReadOnlyList<ExecutableApproval> Executables,
+ bool AllowShell,
+ SandboxJobLimits JobLimits,
+ string NetworkMode,
+ IReadOnlyList<(string Host, IReadOnlyList<int> Ports)> NetworkAllowances,
+ string JournalDirectory,
+ string BackupDirectory,
+ long MemoryBackupLimitBytes,
+ long WholeReadLimitBytes,
+ int SearchResultLimit,
+ int TailLineLimit);
+
+public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurityAuditSink audit, ITaskUpdateHandler? taskUpdates = null) : IInternalToolBackend
+{
+ private readonly ConcurrentDictionary<Guid, TaskContext> _contexts = new();
+ private readonly PathPolicy _paths = new(settings.Files);
+ private readonly ExecutablePolicy _executables = new(settings.Executables, settings.AllowShell);
+ private readonly ITaskUpdateHandler _taskUpdates = taskUpdates ?? new TaskGraphToolHandler();
+
+ public async Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken)
+ {
+ 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);
+ _taskUpdates.BeginTask(taskId);
+ try
+ {
+ foreach ((string path, FileCapability capabilities) in settings.Files
+ .GroupBy(static entry => CanonicalPath.Resolve(entry.Path, false), StringComparer.OrdinalIgnoreCase)
+ .Select(static group => (group.Key, group.Aggregate(FileCapability.None, static (value, item) => value | item.Capabilities))))
+ {
+ // OS/runtime executables may already grant AppContainer execute through their protected system ACL.
+ // Do not rewrite a file ACL when execute is the only requested capability; CreateProcess remains
+ // constrained by AppContainer access plus canonical path/SHA-256 approval.
+ if (File.Exists(path) && capabilities == FileCapability.Execute) continue;
+ context.Roots.Add(path);
+ foreach (string target in ExistingAclTargets(path)) context.Acls.Add(acl.Grant(taskId, target, capabilities));
+ }
+ IReadOnlyList<NetworkEndpoint> allowances = settings.NetworkMode == "allow"
+ ? await NetworkAllowanceSnapshot.ResolveAsync(settings.NetworkAllowances, cancellationToken).ConfigureAwait(false)
+ : [];
+ context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit);
+ if (!_contexts.TryAdd(taskId, context)) throw new InvalidOperationException("Task context already exists.");
+ }
+ catch
+ {
+ try { await CleanupAsync(taskId, context).ConfigureAwait(false); }
+ finally { _taskUpdates.EndTask(taskId); }
+ throw;
+ }
+ }
+
+ public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken)
+ {
+ TaskContext context = Get(taskId);
+ 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(taskId, arguments)),
+ "task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)),
+ _ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.")
+ };
+ }
+
+ public async Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken)
+ {
+ if (!_contexts.TryRemove(taskId, out TaskContext? context)) return;
+ try { await CleanupAsync(taskId, context).ConfigureAwait(false); }
+ finally { _taskUpdates.EndTask(taskId); }
+ }
+
+ private ToolExecutionResult Search(JsonElement arguments)
+ {
+ string root = arguments.GetProperty("root").GetString()!;
+ _paths.Demand(root, FileCapability.Read);
+ string canonical = CanonicalPath.Resolve(root);
+ string mode = arguments.GetProperty("mode").GetString()!;
+ string query = arguments.GetProperty("query").GetString()!;
+ int max = arguments.TryGetProperty("max_results", out JsonElement maxElement) ? maxElement.GetInt32() : 100;
+ max = Math.Min(max, settings.SearchResultLimit);
+ if (mode == "tail")
+ {
+ int lines = arguments.TryGetProperty("tail_lines", out JsonElement lineElement) ? lineElement.GetInt32() : 100;
+ lines = Math.Min(lines, settings.TailLineLimit);
+ if (new FileInfo(canonical).Length > settings.WholeReadLimitBytes * 16) throw new IOException("Tail input is too large.");
+ string[] content = File.ReadLines(canonical).TakeLast(lines).ToArray();
+ return Success(JsonSerializer.Serialize(content));
+ }
+ IEnumerable<string> files = File.Exists(canonical) ? [canonical] : Directory.EnumerateFiles(canonical, "*", new EnumerationOptions
+ {
+ RecurseSubdirectories = true,
+ IgnoreInaccessible = true,
+ ReturnSpecialDirectories = false,
+ AttributesToSkip = FileAttributes.ReparsePoint
+ });
+ IEnumerable<string> results = mode switch
+ {
+ "glob" => files.Where(path => FileSystemName.MatchesSimpleExpression(query, Path.GetRelativePath(canonical, path), ignoreCase: true)),
+ "name" => files.Where(path => Path.GetFileName(path).Contains(query, StringComparison.OrdinalIgnoreCase)),
+ "text" => files.Where(path => IsTextMatch(path, text => text.Contains(query, StringComparison.Ordinal))),
+ "regex" => SearchRegex(files, query),
+ _ => throw new ToolValidationException($"Unsupported search mode '{mode}'.")
+ };
+ return Success(JsonSerializer.Serialize(results.Take(max).Select(Path.GetFullPath).ToArray()));
+ }
+
+ private ToolExecutionResult Read(JsonElement arguments)
+ {
+ string path = arguments.GetProperty("path").GetString()!;
+ _paths.Demand(path, FileCapability.Read);
+ string canonical = CanonicalPath.Resolve(path);
+ string mode = arguments.GetProperty("mode").GetString()!;
+ FileInfo info = new(canonical);
+ if (mode == "metadata") return Success(JsonSerializer.Serialize(new { path = canonical, info.Length, info.LastWriteTimeUtc, sha256 = ExecutablePolicy.ComputeSha256(canonical) }));
+ if (mode == "whole")
+ {
+ if (info.Length > settings.WholeReadLimitBytes) throw new IOException("Whole read exceeds configured limit; use range mode.");
+ return Success(File.ReadAllText(canonical));
+ }
+ long start = arguments.TryGetProperty("start", out JsonElement startElement) ? startElement.GetInt64() : 0;
+ int length = arguments.GetProperty("length").GetInt32();
+ if (length > settings.WholeReadLimitBytes) throw new IOException("Range length exceeds configured limit.");
+ using FileStream stream = File.OpenRead(canonical);
+ stream.Position = start;
+ byte[] buffer = new byte[Math.Min(length, checked((int)Math.Max(0, stream.Length - start)))];
+ _ = stream.Read(buffer);
+ return Success(Encoding.UTF8.GetString(buffer));
+ }
+
+ private ToolExecutionResult Edit(TaskContext context, JsonElement arguments)
+ {
+ string path = arguments.GetProperty("path").GetString()!;
+ string operation = arguments.GetProperty("operation").GetString()!;
+ string? expected = arguments.TryGetProperty("expected_sha256", out JsonElement expectedElement) && expectedElement.ValueKind == JsonValueKind.String ? expectedElement.GetString() : null;
+ var editor = new TransactionalFileEditor(context.Journal, settings.BackupDirectory, settings.MemoryBackupLimitBytes);
+ if (operation == "delete")
+ {
+ _paths.Demand(path, FileCapability.Delete);
+ FileDeleteTransaction transaction = editor.Delete(path, expected);
+ transaction.Commit();
+ }
+ else
+ {
+ _paths.Demand(path, FileCapability.Write);
+ string replacement = operation == "patch"
+ ? ApplyPatch(path, arguments.GetProperty("patch").GetString()!)
+ : arguments.GetProperty("content").GetString()!;
+ FileEditTransaction transaction = editor.Replace(path, replacement, expected);
+ transaction.Commit();
+ }
+ audit.Write(new(DateTimeOffset.UtcNow, context.Journal.TaskId, "file." + operation, "completed", new Dictionary<string, string> { ["path"] = Path.GetFullPath(path) }));
+ return Success($"{operation} completed for {Path.GetFullPath(path)}", externalEffect: true);
+ }
+
+ private ToolExecutionResult RunProcess(Guid taskId, JsonElement arguments)
+ {
+ string executable = arguments.GetProperty("executable").GetString()!;
+ string cwd = arguments.GetProperty("cwd").GetString()!;
+ _paths.Demand(executable, FileCapability.Execute);
+ _paths.Demand(cwd, FileCapability.Read);
+ string[] args = arguments.GetProperty("args").EnumerateArray().Select(static item => item.GetString()!).ToArray();
+ string? managedEntryPoint = ExecutablePolicy.ManagedEntryPoint(executable, args);
+ if (managedEntryPoint is not null) _paths.Demand(managedEntryPoint, FileCapability.Execute);
+ int seconds = arguments.TryGetProperty("timeout_seconds", out JsonElement timeout) ? timeout.GetInt32() : 300;
+ string? stdin = arguments.TryGetProperty("stdin", out JsonElement stdinElement) && stdinElement.ValueKind == JsonValueKind.String ? stdinElement.GetString() : null;
+ RestrictedProcessResult result = new RestrictedProcessLauncher(_executables, audit, AppContainerProfile.EnsureFixed().Sid, settings.JobLimits)
+ .Run(taskId, executable, args, cwd, TimeSpan.FromSeconds(seconds), stdin);
+ string output = $"Process exited with code {result.ExitCode} (PID {result.ProcessId}).\nstdout:\n{result.StandardOutput}\nstderr:\n{result.StandardError}";
+ return new(result.ExitCode == 0, output, true,
+ result.ExitCode == 0 ? null : $"process:{executable}:{result.ExitCode}");
+ }
+
+ private async Task CleanupAsync(Guid taskId, TaskContext context)
+ {
+ List<Exception> failures = [];
+ try { context.Network?.Dispose(); } 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); }
+ var existingSnapshots = context.Acls.Select(static snapshot => snapshot.Path).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (string entry in context.Roots.SelectMany(ExistingAclTargets).Distinct(StringComparer.OrdinalIgnoreCase)
+ .Where(entry => !existingSnapshots.Contains(entry))
+ .OrderBy(static entry => entry.Count(character => character == Path.DirectorySeparatorChar)))
+ try { context.Acl.NormalizeNewEntry(taskId, entry); } 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);
+ }
+
+ private TaskContext Get(Guid taskId) => _contexts.TryGetValue(taskId, out TaskContext? context) ? context : throw new InvalidOperationException("Task Resource scope is not active.");
+ private static ToolExecutionResult Success(string output, bool externalEffect = false) => new(true, output, externalEffect);
+ private static bool IsTextMatch(string path, Func<string, bool> predicate)
+ {
+ try { return new FileInfo(path).Length <= 1024 * 1024 && predicate(File.ReadAllText(path)); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DecoderFallbackException) { return false; }
+ }
+ private static IEnumerable<string> SearchRegex(IEnumerable<string> files, string query)
+ {
+ var regex = new Regex(query, RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
+ return files.Where(path => IsTextMatch(path, regex.IsMatch));
+ }
+ private static IEnumerable<string> ExistingAclTargets(string path)
+ {
+ if (File.Exists(path))
+ {
+ yield return path;
+ yield break;
+ }
+ if (!Directory.Exists(path)) yield break;
+ var options = new EnumerationOptions
+ {
+ RecurseSubdirectories = true,
+ IgnoreInaccessible = false,
+ ReturnSpecialDirectories = false,
+ AttributesToSkip = FileAttributes.ReparsePoint
+ };
+ foreach (string entry in Directory.EnumerateFileSystemEntries(path, "*", options)
+ .OrderByDescending(static entry => entry.Count(character => character == Path.DirectorySeparatorChar)))
+ yield return entry;
+ yield return path;
+ }
+ private static string ApplyPatch(string path, string patch)
+ {
+ string current = File.Exists(path) ? File.ReadAllText(path) : string.Empty;
+ using JsonDocument document = JsonDocument.Parse(patch);
+ string oldText = document.RootElement.GetProperty("old_text").GetString()!;
+ string newText = document.RootElement.GetProperty("new_text").GetString()!;
+ int first = current.IndexOf(oldText, StringComparison.Ordinal);
+ if (first < 0 || current.IndexOf(oldText, first + oldText.Length, StringComparison.Ordinal) >= 0) throw new InvalidOperationException("Patch old_text must match exactly once.");
+ return current[..first] + newText + current[(first + oldText.Length)..];
+ }
+
+ private sealed class TaskContext(TaskResourceJournal journal, TaskAclManager acl)
+ {
+ public TaskResourceJournal Journal { get; } = journal;
+ public TaskAclManager Acl { get; } = acl;
+ public List<AclSnapshot> Acls { get; } = [];
+ public HashSet<string> Roots { get; } = new(StringComparer.OrdinalIgnoreCase);
+ public TaskNetworkPolicy? Network { get; set; }
+ }
+}