From b07b41cd435991cd254957af79055080c85b346e Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 9 Aug 2026 21:08:20 +0900 Subject: feat: AI Agent Sandbox MCP Server v1を実装 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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まで生成可能にした。 --- src/Agent.Windows/Agent.Windows.csproj | 17 ++ src/Agent.Windows/AssemblyMarker.cs | 3 + .../Diagnostics/WindowsPlatformCheck.cs | 50 ++++ src/Agent.Windows/NativeMethods.txt | 46 ++++ src/Agent.Windows/Network/LoopbackExemption.cs | 123 ++++++++++ src/Agent.Windows/Network/TaskNetworkPolicy.cs | 73 ++++++ src/Agent.Windows/Network/WfpDynamicSession.cs | 208 +++++++++++++++++ src/Agent.Windows/Recovery/RecoveryManager.cs | 114 +++++++++ src/Agent.Windows/Recovery/TaskResourceJournal.cs | 108 +++++++++ .../Recovery/TransactionalFileEditor.cs | 141 +++++++++++ src/Agent.Windows/Security/AppContainerProfile.cs | 41 ++++ src/Agent.Windows/Security/ExecutablePolicy.cs | 51 ++++ src/Agent.Windows/Security/PathPolicy.cs | 52 +++++ .../Security/RestrictedProcessLauncher.cs | 255 ++++++++++++++++++++ src/Agent.Windows/Security/SecurityAudit.cs | 29 +++ src/Agent.Windows/Security/TaskAclManager.cs | 76 ++++++ src/Agent.Windows/Security/UserOnlyDirectory.cs | 20 ++ src/Agent.Windows/Tools/WindowsToolBackend.cs | 257 +++++++++++++++++++++ 18 files changed, 1664 insertions(+) create mode 100644 src/Agent.Windows/Agent.Windows.csproj create mode 100644 src/Agent.Windows/AssemblyMarker.cs create mode 100644 src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs create mode 100644 src/Agent.Windows/NativeMethods.txt create mode 100644 src/Agent.Windows/Network/LoopbackExemption.cs create mode 100644 src/Agent.Windows/Network/TaskNetworkPolicy.cs create mode 100644 src/Agent.Windows/Network/WfpDynamicSession.cs create mode 100644 src/Agent.Windows/Recovery/RecoveryManager.cs create mode 100644 src/Agent.Windows/Recovery/TaskResourceJournal.cs create mode 100644 src/Agent.Windows/Recovery/TransactionalFileEditor.cs create mode 100644 src/Agent.Windows/Security/AppContainerProfile.cs create mode 100644 src/Agent.Windows/Security/ExecutablePolicy.cs create mode 100644 src/Agent.Windows/Security/PathPolicy.cs create mode 100644 src/Agent.Windows/Security/RestrictedProcessLauncher.cs create mode 100644 src/Agent.Windows/Security/SecurityAudit.cs create mode 100644 src/Agent.Windows/Security/TaskAclManager.cs create mode 100644 src/Agent.Windows/Security/UserOnlyDirectory.cs create mode 100644 src/Agent.Windows/Tools/WindowsToolBackend.cs (limited to 'src/Agent.Windows') 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 @@ + + + net10.0-windows10.0.26100.0 + true + true + $(IntermediateOutputPath)Generated + + + + + + + + + + + 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 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 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 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 Read() + { + uint result = PInvoke.NetworkIsolationGetAppContainerConfig(out uint count, out SID_AND_ATTRIBUTES* native); + ThrowIfError(result, "NetworkIsolationGetAppContainerConfig"); + try + { + var entries = new List(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 entries) + { + var native = new SID_AND_ATTRIBUTES[entries.Count]; + var allocations = new List(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 allowances, ISecurityAuditSink audit) + { + bool originallyExempt = LoopbackExemption.Contains(appContainerSid); + Guid loopbackEntryId = journal.Prepare("network.loopback_exemption", appContainerSid.Value, + new Dictionary { ["originally_present"] = originallyExempt.ToString() }); + bool loopbackAdded = false; + Guid entryId = journal.Prepare("wfp.dynamic", appContainerSid.Value, new Dictionary + { + ["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 { ["added"] = loopbackAdded.ToString() }); + IReadOnlyList ids = session.ApplyDefaultBlock(appContainerSid, allowances); + journal.MarkApplied(entryId, new Dictionary { ["filter_ids"] = string.Join(',', ids) }); + audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "wfp.apply", "applied", new Dictionary + { + ["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 { ["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 { ["residual_filters"] = "0" })); + _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed", + new Dictionary { ["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> ResolveAsync(IEnumerable<(string Host, IReadOnlyList Ports)> allowances, CancellationToken cancellationToken) + { + var endpoints = new HashSet(); + foreach ((string host, IReadOnlyList 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 _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 ApplyDefaultBlock(SecurityIdentifier appContainerSid, IReadOnlyList? 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 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(); + 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())); + } + 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 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 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 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(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 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? 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 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 + { + ["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 { ["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 + { + ["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 approvals, bool allowShell) +{ + private readonly IReadOnlyDictionary _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 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 entries) +{ + private readonly IReadOnlyList _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 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 { ["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 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 + { + ["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 + { + ["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 { ["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 { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() })); + if (exitCode != 0) + { + audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "observed", new Dictionary + { + ["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 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 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 _events = []; + public IReadOnlyList 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 + { + ["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 { ["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 { ["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() + .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 { ["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 Files, + IReadOnlyList Executables, + bool AllowShell, + SandboxJobLimits JobLimits, + string NetworkMode, + IReadOnlyList<(string Host, IReadOnlyList 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 _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 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 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 files = File.Exists(canonical) ? [canonical] : Directory.EnumerateFiles(canonical, "*", new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + ReturnSpecialDirectories = false, + AttributesToSkip = FileAttributes.ReparsePoint + }); + IEnumerable 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 { ["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 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 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 SearchRegex(IEnumerable files, string query) + { + var regex = new Regex(query, RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)); + return files.Where(path => IsTextMatch(path, regex.IsMatch)); + } + private static IEnumerable 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 Acls { get; } = []; + public HashSet Roots { get; } = new(StringComparer.OrdinalIgnoreCase); + public TaskNetworkPolicy? Network { get; set; } + } +} -- cgit v1.2.3