using System.Diagnostics; using System.Net; using System.Net.Sockets; using System.Security.AccessControl; using System.Text; using Agent.Windows.Recovery; using Agent.Windows.Security; using Agent.Windows.Network; using Agent.Windows.Tools; using Agent.Core.Tools; using System.Text.Json; namespace Agent.Windows.Tests; public sealed class SecurityPolicyTests { [Fact] public void Process_timeout_uses_configured_default_and_rejects_higher_values() { using JsonDocument omitted = JsonDocument.Parse("{}"); Assert.Equal(45, WindowsToolBackend.ResolveProcessTimeout(omitted.RootElement, 45)); using JsonDocument exact = JsonDocument.Parse("{\"timeout_seconds\":45}"); Assert.Equal(45, WindowsToolBackend.ResolveProcessTimeout(exact.RootElement, 45)); using JsonDocument tooHigh = JsonDocument.Parse("{\"timeout_seconds\":46}"); Assert.Throws(() => WindowsToolBackend.ResolveProcessTimeout(tooHigh.RootElement, 45)); } [Fact] public void Read_write_and_delete_capabilities_are_independent() { using TestDirectory directory = new(); string file = Path.Combine(directory.Path, "file.txt"); File.WriteAllText(file, "data"); PathPolicy policy = new([new FilePolicyEntry(directory.Path, FileCapability.Read | FileCapability.Write)]); Assert.True(policy.IsAllowed(file, FileCapability.Read)); Assert.True(policy.IsAllowed(Path.Combine(directory.Path, "new.txt"), FileCapability.Write)); Assert.False(policy.IsAllowed(file, FileCapability.Delete)); Assert.Throws(() => policy.Demand(file, FileCapability.Delete)); } [Fact] public void Path_outside_approved_root_is_denied() { using TestDirectory approved = new(); using TestDirectory outside = new(); string secret = Path.Combine(outside.Path, "secret.txt"); File.WriteAllText(secret, "secret"); PathPolicy policy = new([new FilePolicyEntry(approved.Path, FileCapability.Read)]); Assert.False(policy.IsAllowed(secret, FileCapability.Read)); } [Fact] public void Reparse_escape_is_rejected() { using TestDirectory approved = new(); using TestDirectory outside = new(); string link = Path.Combine(approved.Path, "escape"); using Process junction = Process.Start(new ProcessStartInfo { FileName = Environment.GetEnvironmentVariable("ComSpec")!, UseShellExecute = false, CreateNoWindow = true, ArgumentList = { "/d", "/c", "mklink", "/J", link, outside.Path } })!; junction.WaitForExit(); Assert.Equal(0, junction.ExitCode); PathPolicy policy = new([new FilePolicyEntry(approved.Path, FileCapability.Read)]); try { SandboxPolicyException error = Assert.Throws(() => policy.IsAllowed(Path.Combine(link, "secret.txt"), FileCapability.Read)); Assert.Equal("path.reparse", error.Code); } finally { Directory.Delete(link); } } [Fact] public void Symlink_chain_escape_is_rejected() { using TestDirectory approved = new(); using TestDirectory outside = new(); string second = Path.Combine(approved.Path, "second-link"); string first = Path.Combine(approved.Path, "first-link"); Directory.CreateSymbolicLink(second, outside.Path); Directory.CreateSymbolicLink(first, second); PathPolicy policy = new([new FilePolicyEntry(approved.Path, FileCapability.Read)]); SandboxPolicyException error = Assert.Throws(() => policy.IsAllowed(Path.Combine(first, "secret.txt"), FileCapability.Read)); Assert.Equal("path.reparse", error.Code); Directory.Delete(first); Directory.Delete(second); } [Fact] public void Executable_requires_approved_path_and_hash_and_shell_toggle() { string executable = Environment.ProcessPath!; string hash = ExecutablePolicy.ComputeSha256(executable); Assert.Equal(hash, new ExecutablePolicy([new ExecutableApproval(executable, hash)], true).Verify(executable).Sha256); Assert.Throws(() => new ExecutablePolicy([], true).Verify(executable)); string badHash = new string(hash[0] == '0' ? '1' : '0', 1) + hash[1..]; Assert.Throws(() => new ExecutablePolicy([new ExecutableApproval(executable, badHash)], true).Verify(executable)); string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); Assert.Throws(() => new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], false).Verify(powershell)); } [Fact] public void Restricted_parent_cannot_start_unbrokered_descendant() { using TestDirectory directory = new(); string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); var audit = new MemorySecurityAuditSink(); var launcher = new RestrictedProcessLauncher( new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), audit); string copied = Path.Combine(directory.Path, "renamed-child.exe"); File.Copy(Environment.GetEnvironmentVariable("ComSpec")!, copied); string[] markers = Enumerable.Range(1, 4).Select(index => Path.Combine(directory.Path, $"descendant-{index}.txt")).ToArray(); string[] commands = [ $"$ErrorActionPreference='Stop'; Start-Process -FilePath $env:ComSpec -ArgumentList '/d','/c','echo compromised > \"{markers[0]}\"' -Wait", $"$ErrorActionPreference='Stop'; [Diagnostics.Process]::Start($env:ComSpec, '/d /c echo compromised ^> \"{markers[1]}\"').WaitForExit()", $"$ErrorActionPreference='Stop'; & $env:ComSpec /d /c 'echo compromised > \"{markers[2]}\"'", $"$ErrorActionPreference='Stop'; Start-Process -FilePath '{copied}' -ArgumentList '/d','/c','echo compromised > \"{markers[3]}\"' -Wait" ]; foreach (string command in commands) { RestrictedProcessResult result = launcher.Run(Guid.NewGuid(), powershell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], directory.Path, TimeSpan.FromSeconds(30)); Assert.NotEqual(0u, result.ExitCode); } Assert.All(markers, marker => Assert.False(File.Exists(marker))); Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["child_process_policy"] == "restricted"); Assert.Contains(audit.Events, item => item.Event == "process.exit" && item.Data["exit_code"] != "0"); Assert.Equal(commands.Length, audit.Events.Count(item => item.Event == "policy.deny" && item.Data["child_process_policy"] == "restricted")); } [Fact] public void Job_timeout_terminates_the_entire_sandbox_job_without_orphan() { string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); var audit = new MemorySecurityAuditSink(); var launcher = new RestrictedProcessLauncher( new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), audit); Assert.Throws(() => launcher.Run(Guid.NewGuid(), powershell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30"], Environment.SystemDirectory, TimeSpan.FromMilliseconds(250))); SecurityAuditEvent timeout = Assert.Single(audit.Events, item => item.Event == "process.timeout"); int pid = int.Parse(timeout.Data["pid"], System.Globalization.CultureInfo.InvariantCulture); Assert.Throws(() => Process.GetProcessById(pid)); } [Fact] public void Dotnet_managed_entry_requires_its_own_path_and_hash_approval() { string dotnet = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet", "dotnet.exe"); string managedAssembly = typeof(SecurityPolicyTests).Assembly.Location; var audit = new MemorySecurityAuditSink(); var launcher = new RestrictedProcessLauncher( new ExecutablePolicy([new ExecutableApproval(dotnet, ExecutablePolicy.ComputeSha256(dotnet))], true), audit); SandboxPolicyException denied = Assert.Throws(() => launcher.Run( Guid.NewGuid(), dotnet, [managedAssembly], Path.GetDirectoryName(managedAssembly)!, TimeSpan.FromSeconds(5))); Assert.Equal("process.path_denied", denied.Code); Assert.Contains(audit.Events, item => item.Event == "policy.deny" && item.Data["code"] == "process.path_denied"); } [Fact] public void Fixed_app_container_is_stable_and_acl_is_restored_exactly() { AppContainerProfile first = AppContainerProfile.EnsureFixed(); AppContainerProfile second = AppContainerProfile.EnsureFixed(); Assert.Equal(AppContainerProfile.FixedName, first.Name); Assert.Equal(first.Sid.Value, second.Sid.Value); using TestDirectory directory = new(); byte[] before = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(directory.Path), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); var audit = new MemorySecurityAuditSink(); TaskAclManager manager = new(first.Sid, audit); Guid taskId = Guid.NewGuid(); AclSnapshot snapshot = manager.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write); byte[] during = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(directory.Path), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); Assert.NotEqual(Convert.ToHexString(before), Convert.ToHexString(during)); manager.Restore(taskId, snapshot); byte[] after = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(directory.Path), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); Assert.Equal(Convert.ToHexString(before), Convert.ToHexString(after)); Assert.Contains(audit.Events, item => item.Event == "acl.apply"); Assert.Contains(audit.Events, item => item.Event == "acl.restore"); } [Fact] public void Root_acl_inheritance_covers_existing_and_new_descendants_and_restores_exactly() { using TestDirectory directory = new(); using TestDirectory state = new(); string existingDirectory = Path.Combine(directory.Path, "existing"); string existingFile = Path.Combine(existingDirectory, "before.txt"); Directory.CreateDirectory(existingDirectory); File.WriteAllText(existingFile, "before"); byte[] rootBefore = AccessDescriptor(directory.Path); byte[] existingBefore = AccessDescriptor(existingFile); var sid = new System.Security.Principal.SecurityIdentifier( "S-1-15-2-310000001-310000002-310000003-310000004-310000005-310000006-310000007"); var audit = new MemorySecurityAuditSink(); Guid taskId = Guid.NewGuid(); TaskResourceJournal journal = TaskResourceJournal.Create(Path.Combine(state.Path, "journals"), taskId); TaskAclManager manager = new(sid, audit, journal); AclSnapshot snapshot = manager.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write); JournalEntry aclEntry = Assert.Single(journal.Entries); Assert.Equal("acl", aclEntry.Kind); Assert.Equal(directory.Path, aclEntry.Target, ignoreCase: true); Assert.Contains(AccessRules(directory.Path, sid), static rule => !rule.IsInherited); Assert.Contains(AccessRules(existingFile, sid), static rule => rule.IsInherited); string createdDirectory = Path.Combine(directory.Path, "created"); string createdFile = Path.Combine(createdDirectory, "during.txt"); Directory.CreateDirectory(createdDirectory); File.WriteAllText(createdFile, "during"); Assert.Contains(AccessRules(createdFile, sid), static rule => rule.IsInherited); manager.Restore(taskId, snapshot); journal.CompleteAndDelete(); Assert.Equal(Convert.ToHexString(rootBefore), Convert.ToHexString(AccessDescriptor(directory.Path))); Assert.Equal(Convert.ToHexString(existingBefore), Convert.ToHexString(AccessDescriptor(existingFile))); Assert.Empty(AccessRules(existingFile, sid)); Assert.Empty(AccessRules(createdFile, sid)); SecurityAuditEvent applied = Assert.Single(audit.Events, item => item.Event == "acl.apply"); Assert.Equal("root_inheritance", applied.Data["scope"]); Assert.Single(audit.Events, item => item.Event == "acl.restore"); Assert.Empty(Directory.EnumerateFiles(Path.Combine(state.Path, "journals"))); } [Fact] public void Root_acl_inheritance_does_not_override_a_protected_descendant() { using TestDirectory directory = new(); string protectedDirectory = Path.Combine(directory.Path, "protected"); Directory.CreateDirectory(protectedDirectory); DirectorySecurity protectedAcl = FileSystemAclExtensions.GetAccessControl( new DirectoryInfo(protectedDirectory), AccessControlSections.Access); protectedAcl.SetAccessRuleProtection(isProtected: true, preserveInheritance: true); FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(protectedDirectory), protectedAcl); var sid = new System.Security.Principal.SecurityIdentifier( "S-1-15-2-320000001-320000002-320000003-320000004-320000005-320000006-320000007"); TaskAclManager manager = new(sid, new MemorySecurityAuditSink()); AclSnapshot snapshot = manager.Grant(Guid.NewGuid(), directory.Path, FileCapability.Read); try { Assert.Empty(AccessRules(protectedDirectory, sid)); } finally { manager.Restore(Guid.NewGuid(), snapshot); } } [Fact] public void Brokered_process_runs_in_fixed_app_container_with_task_acl() { AppContainerProfile profile = AppContainerProfile.EnsureFixed(); using TestDirectory directory = new(); string marker = Path.Combine(directory.Path, "appcontainer-write.txt"); string commandProcessor = Environment.GetEnvironmentVariable("ComSpec")!; var audit = new MemorySecurityAuditSink(); TaskAclManager acl = new(profile.Sid, audit); Guid taskId = Guid.NewGuid(); AclSnapshot snapshot = acl.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write); try { var launcher = new RestrictedProcessLauncher( new ExecutablePolicy([new ExecutableApproval(commandProcessor, ExecutablePolicy.ComputeSha256(commandProcessor))], true), audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024)); RestrictedProcessResult result = launcher.Run(taskId, commandProcessor, ["/d", "/c", $"echo APP_OK>{marker} & echo CAPTURE_OK"], directory.Path, TimeSpan.FromSeconds(30)); Assert.Equal(0u, result.ExitCode); Assert.Equal("APP_OK", File.ReadAllText(marker).Trim()); Assert.Contains("CAPTURE_OK", result.StandardOutput, StringComparison.Ordinal); Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["app_container_sid"] == profile.Sid.Value); } finally { acl.Restore(taskId, snapshot); } } [Fact] public void File_transaction_commit_rollback_and_hash_guard_leave_no_journal() { using TestDirectory directory = new(); string journals = Path.Combine(directory.Path, "journals"); string backups = Path.Combine(directory.Path, "backups"); string target = Path.Combine(directory.Path, "target.txt"); File.WriteAllText(target, "original"); TaskResourceJournal journal = TaskResourceJournal.Create(journals, Guid.NewGuid()); TransactionalFileEditor editor = new(journal, backups, memoryBackupLimitBytes: 1); FileEditTransaction rollback = editor.Replace(target, "replacement"); Assert.Equal("replacement", File.ReadAllText(target)); rollback.Rollback(); Assert.Equal("original", File.ReadAllText(target)); FileEditTransaction commit = editor.Replace(target, "committed"); commit.Commit(); journal.CompleteAndDelete(); Assert.Equal("committed", File.ReadAllText(target)); Assert.Empty(Directory.EnumerateFiles(journals)); Assert.False(Directory.Exists(Path.Combine(backups, journal.TaskId.ToString("N")))); } [Fact] public void Recovery_rolls_back_applied_file_in_reverse_cleanup_path() { using TestDirectory directory = new(); string journals = Path.Combine(directory.Path, "journals"); string backups = Path.Combine(directory.Path, "backups"); string target = Path.Combine(directory.Path, "target.txt"); File.WriteAllText(target, "before-crash"); TaskResourceJournal journal = TaskResourceJournal.Create(journals, Guid.NewGuid()); _ = new TransactionalFileEditor(journal, backups, memoryBackupLimitBytes: 1).Replace(target, "after-crash"); RecoveryResult result = new RecoveryManager(journals, new MemorySecurityAuditSink()).RecoverAll(); Assert.True(result.IsClean); Assert.Equal("before-crash", File.ReadAllText(target)); Assert.Empty(Directory.EnumerateFiles(journals)); Assert.False(Directory.Exists(Path.Combine(backups, journal.TaskId.ToString("N")))); } [Fact] public void Recovery_after_crash_restores_acl_loopback_and_dynamic_wfp_state() { AppContainerProfile profile = AppContainerProfile.EnsureFixed(); // Pre-clean any residual loopback exemption from prior test failures. LoopbackExemption.Remove(profile.Sid); using TestDirectory directory = new(); string journals = Path.Combine(directory.Path, "journals"); string resource = Path.Combine(directory.Path, "resource"); Directory.CreateDirectory(resource); string child = Path.Combine(resource, "existing.txt"); File.WriteAllText(child, "existing"); byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resource), AccessControlSections.Access) .GetSecurityDescriptorBinaryForm(); byte[] childAclBefore = AccessDescriptor(child); var audit = new MemorySecurityAuditSink(); Guid taskId = Guid.NewGuid(); TaskResourceJournal journal = TaskResourceJournal.Create(journals, taskId); var acl = new TaskAclManager(profile.Sid, audit, journal); _ = acl.Grant(taskId, resource, FileCapability.Read | FileCapability.Write); Assert.Contains(AccessRules(child, profile.Sid), static rule => rule.IsInherited); Assert.False(LoopbackExemption.Contains(profile.Sid)); Guid loopbackEntry = journal.Prepare("network.loopback_exemption", profile.Sid.Value, new Dictionary { ["originally_present"] = bool.FalseString }); Assert.True(LoopbackExemption.Ensure(profile.Sid)); journal.MarkApplied(loopbackEntry, new Dictionary { ["added"] = bool.TrueString }); Guid wfpEntry = journal.Prepare("wfp.dynamic", profile.Sid.Value, new Dictionary { ["session"] = "dynamic", ["allowances"] = string.Empty }); using (WfpDynamicSession crashedSession = WfpDynamicSession.Open()) { IReadOnlyList filterIds = crashedSession.ApplyDefaultBlock(profile.Sid); journal.MarkApplied(wfpEntry, new Dictionary { ["filter_ids"] = string.Join(',', filterIds) }); // Closing the engine without RemovePolicy models abrupt process exit: // BFE owns and deletes every object in this dynamic session. } try { RecoveryResult recovered = new RecoveryManager(journals, audit).RecoverAll(); Assert.True(recovered.IsClean); Assert.Equal(1, recovered.JournalsRecovered); Assert.Equal(3, recovered.ResourcesRecovered); Assert.False(LoopbackExemption.Contains(profile.Sid)); byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resource), AccessControlSections.Access) .GetSecurityDescriptorBinaryForm(); Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter)); Assert.Equal(Convert.ToHexString(childAclBefore), Convert.ToHexString(AccessDescriptor(child))); Assert.Empty(AccessRules(child, profile.Sid)); Assert.Empty(Directory.EnumerateFiles(journals)); } finally { // Ensure loopback exemption is always cleaned up, even if assertions fail. LoopbackExemption.Remove(profile.Sid); } } [Theory] [InlineData("acl")] [InlineData("acl-before")] [InlineData("file-before")] [InlineData("file-replace")] [InlineData("process-after")] [InlineData("cleanup-mid")] [InlineData("wfp")] public void Independent_process_crash_is_fully_recovered_on_next_start(string scenario) { // Pre-clean any residual loopback exemption from prior test failures. LoopbackExemption.Remove(AppContainerProfile.EnsureFixed().Sid); using TestDirectory directory = new(); string resourceDirectory = Path.Combine(directory.Path, "resource"); string resourceFile = Path.Combine(directory.Path, "resource.txt"); Directory.CreateDirectory(resourceDirectory); string childFile = Path.Combine(resourceDirectory, "child.txt"); File.WriteAllText(childFile, "child-before-crash"); File.WriteAllText(resourceFile, "before-crash"); byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resourceDirectory), AccessControlSections.Access) .GetSecurityDescriptorBinaryForm(); byte[] childAclBefore = AccessDescriptor(childFile); Guid taskId = Guid.NewGuid(); using var child = new Process { StartInfo = new ProcessStartInfo { FileName = Environment.ProcessPath!, UseShellExecute = false, CreateNoWindow = true, Environment = { ["AGENT_MCP_CRASH_SCENARIO"] = scenario, ["AGENT_MCP_CRASH_ROOT"] = directory.Path, ["AGENT_MCP_CRASH_TASK"] = taskId.ToString("D") } } }; Assert.True(child.Start()); Assert.True(child.WaitForExit(60_000)); Assert.NotEqual(0, child.ExitCode); Assert.False(File.Exists(Path.Combine(directory.Path, "crash-error.txt")), File.Exists(Path.Combine(directory.Path, "crash-error.txt")) ? File.ReadAllText(Path.Combine(directory.Path, "crash-error.txt")) : string.Empty); Assert.Single(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals"))); RecoveryResult recovered = new RecoveryManager(Path.Combine(directory.Path, "journals"), new MemorySecurityAuditSink()).RecoverAll(); Assert.True(recovered.IsClean, string.Join(Environment.NewLine, recovered.Errors)); Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals"))); Assert.Equal("before-crash", File.ReadAllText(resourceFile)); byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resourceDirectory), AccessControlSections.Access) .GetSecurityDescriptorBinaryForm(); Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter)); Assert.Equal(Convert.ToHexString(childAclBefore), Convert.ToHexString(AccessDescriptor(childFile))); Assert.Empty(AccessRules(childFile, AppContainerProfile.EnsureFixed().Sid)); Assert.False(LoopbackExemption.Contains(AppContainerProfile.EnsureFixed().Sid)); string backups = Path.Combine(directory.Path, "backups"); Assert.True(!Directory.Exists(backups) || !Directory.EnumerateFileSystemEntries(backups).Any()); if (scenario == "process-after") { string auditPath = Path.Combine(directory.Path, "crash-audit.jsonl"); using JsonDocument audit = JsonDocument.Parse(File.ReadLines(auditPath).Single(line => line.Contains("process.start", StringComparison.Ordinal))); int sandboxPid = int.Parse(audit.RootElement.GetProperty("data").GetProperty("pid").GetString()!, System.Globalization.CultureInfo.InvariantCulture); Assert.True(SpinWait.SpinUntil(() => !ProcessExists(sandboxPid), TimeSpan.FromSeconds(5)), $"Orphan sandbox PID {sandboxPid} survived broker crash."); } } [Fact] public void Independent_process_crash_recovers_root_inheritance_from_existing_child() { using TestDirectory directory = new(); string resource = Path.Combine(directory.Path, "resource"); string childFile = Path.Combine(resource, "child.txt"); Directory.CreateDirectory(resource); File.WriteAllText(childFile, "before-crash"); byte[] rootBefore = AccessDescriptor(resource); byte[] childBefore = AccessDescriptor(childFile); var sid = new System.Security.Principal.SecurityIdentifier( "S-1-15-2-330000001-330000002-330000003-330000004-330000005-330000006-330000007"); Guid taskId = Guid.NewGuid(); using var child = new Process { StartInfo = new ProcessStartInfo { FileName = Environment.ProcessPath!, UseShellExecute = false, CreateNoWindow = true, Environment = { ["AGENT_MCP_CRASH_SCENARIO"] = "acl-inheritance", ["AGENT_MCP_CRASH_ROOT"] = directory.Path, ["AGENT_MCP_CRASH_TASK"] = taskId.ToString("D") } } }; Assert.True(child.Start()); Assert.True(child.WaitForExit(30_000)); Assert.NotEqual(0, child.ExitCode); Assert.False(File.Exists(Path.Combine(directory.Path, "crash-error.txt")), File.Exists(Path.Combine(directory.Path, "crash-error.txt")) ? File.ReadAllText(Path.Combine(directory.Path, "crash-error.txt")) : string.Empty); Assert.Contains(AccessRules(childFile, sid), static rule => rule.IsInherited); RecoveryResult recovered = new RecoveryManager(Path.Combine(directory.Path, "journals"), new MemorySecurityAuditSink()).RecoverAll(); Assert.True(recovered.IsClean, string.Join(Environment.NewLine, recovered.Errors)); Assert.Equal(Convert.ToHexString(rootBefore), Convert.ToHexString(AccessDescriptor(resource))); Assert.Equal(Convert.ToHexString(childBefore), Convert.ToHexString(AccessDescriptor(childFile))); Assert.Empty(AccessRules(childFile, sid)); Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals"))); } private static bool ProcessExists(int pid) { try { using Process process = Process.GetProcessById(pid); return !process.HasExited; } catch (ArgumentException) { return false; } } [Fact] public void Rollback_refuses_to_overwrite_post_transaction_external_change() { using TestDirectory directory = new(); string target = Path.Combine(directory.Path, "target.txt"); File.WriteAllText(target, "before"); TaskResourceJournal journal = TaskResourceJournal.Create(Path.Combine(directory.Path, "journals"), Guid.NewGuid()); FileEditTransaction transaction = new TransactionalFileEditor(journal, Path.Combine(directory.Path, "backups")).Replace(target, "replacement"); File.WriteAllText(target, "external-change"); Assert.Throws(() => transaction.Rollback()); File.WriteAllText(target, "replacement"); transaction.Rollback(); journal.CompleteAndDelete(); } [Fact] public void Wfp_dynamic_session_adds_and_explicitly_removes_ipv4_ipv6_package_filters() { AppContainerProfile profile = AppContainerProfile.EnsureFixed(); using WfpDynamicSession session = WfpDynamicSession.Open(); session.ProbeWriteTransaction(); IReadOnlyList ids = session.ApplyDefaultBlock(profile.Sid, [new NetworkEndpoint(IPAddress.Loopback, 11434), new NetworkEndpoint(IPAddress.IPv6Loopback, 11434)]); Assert.Equal(4, ids.Count); Assert.All(ids, id => Assert.NotEqual(0UL, id)); session.RemovePolicy(); } [Fact] public async Task Windows_backend_applies_file_effect_and_cleans_acl_wfp_temp_and_journal() { using TestDirectory directory = new(); string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); string target = Path.Combine(workspace, "target.txt"); File.WriteAllText(target, "before"); byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(workspace), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); var audit = new MemorySecurityAuditSink(); var settings = new WindowsSandboxSettings( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false, new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit); Guid taskId = Guid.NewGuid(); Guid sessionId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, sessionId, TestContext.Current.CancellationToken); using JsonDocument edit = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"overwrite","content":"after"}"""); ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_edit", edit.RootElement, TestContext.Current.CancellationToken); await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.True(result.ExternalEffect); Assert.Equal("after", File.ReadAllText(target)); byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(workspace), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter)); Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals"))); string backups = Path.Combine(directory.Path, "backups"); Assert.True(!Directory.Exists(backups) || !Directory.EnumerateFileSystemEntries(backups).Any()); Assert.Contains(audit.Events, item => item.Event == "network.rule.remove" && item.Data["residual_filters"] == "0"); Assert.Contains(audit.Events, item => item.Event == "task.start" && item.SessionId == sessionId); Assert.Contains(audit.Events, item => item.Event == "tool.call" && item.SessionId == sessionId); Assert.Contains(audit.Events, item => item.Event == "file.write" && item.SessionId == sessionId); Assert.Contains(audit.Events, item => item.Event == "task.end" && item.SessionId == sessionId); Assert.Single(audit.Events, item => item.Event == "acl.apply"); Assert.Single(audit.Events, item => item.Event == "acl.restore"); } [Fact] public async Task Dir_mkdir_creates_directory_within_approved_root_and_cleans_up() { using TestDirectory directory = new(); string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); string target = Path.Combine(workspace, "subdir"); byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(workspace), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); var audit = new MemorySecurityAuditSink(); var settings = new WindowsSandboxSettings( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false, new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit); Guid taskId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); using JsonDocument mkdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"mkdir"}"""); ToolExecutionResult result = await backend.ExecuteAsync(taskId, "dir", mkdir.RootElement, TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.True(result.ExternalEffect); Assert.True(Directory.Exists(target)); await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); Assert.True(Directory.Exists(target)); // mkdir result persists after cleanup byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(workspace), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter)); Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals"))); } [Fact] public async Task Dir_rmdir_removes_empty_directory_within_approved_root() { using TestDirectory directory = new(); string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); string target = Path.Combine(workspace, "subdir"); Directory.CreateDirectory(target); var audit = new MemorySecurityAuditSink(); var settings = new WindowsSandboxSettings( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false, new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit); Guid taskId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); using JsonDocument rmdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"rmdir"}"""); ToolExecutionResult result = await backend.ExecuteAsync(taskId, "dir", rmdir.RootElement, TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.True(result.ExternalEffect); Assert.False(Directory.Exists(target)); await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals"))); } [Fact] public async Task Dir_rmdir_rejects_non_empty_directory() { using TestDirectory directory = new(); string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); string target = Path.Combine(workspace, "subdir"); Directory.CreateDirectory(target); File.WriteAllText(Path.Combine(target, "child.txt"), "content"); var audit = new MemorySecurityAuditSink(); var settings = new WindowsSandboxSettings( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false, new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit); Guid taskId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); using JsonDocument rmdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"rmdir"}"""); await Assert.ThrowsAsync(async () => await backend.ExecuteAsync(taskId, "dir", rmdir.RootElement, TestContext.Current.CancellationToken)); Assert.True(Directory.Exists(target)); // directory still exists await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); } [Fact] public async Task Dir_list_returns_entries_in_approved_directory() { using TestDirectory directory = new(); string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); Directory.CreateDirectory(Path.Combine(workspace, "child-dir")); File.WriteAllText(Path.Combine(workspace, "child.txt"), "content"); var audit = new MemorySecurityAuditSink(); var settings = new WindowsSandboxSettings( [new FilePolicyEntry(workspace, FileCapability.Read)], [], false, new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit); Guid taskId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); using JsonDocument list = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(workspace)}},"operation":"list"}"""); ToolExecutionResult result = await backend.ExecuteAsync(taskId, "dir", list.RootElement, TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.False(result.ExternalEffect); Assert.Contains("child-dir", result.Output); Assert.Contains("child.txt", result.Output); await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); } [Fact] public async Task Dir_mkdir_outside_approved_root_is_denied() { using TestDirectory directory = new(); using TestDirectory outside = new(); string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); var audit = new MemorySecurityAuditSink(); var settings = new WindowsSandboxSettings( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], false, new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit); Guid taskId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); string badTarget = Path.Combine(outside.Path, "escaped"); using JsonDocument mkdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(badTarget)}},"operation":"mkdir"}"""); ToolExecutionResult result = await backend.ExecuteAsync(taskId, "dir", mkdir.RootElement, TestContext.Current.CancellationToken); Assert.False(result.Success); Assert.False(result.RetryAllowed); await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); } [Fact] public async Task Tail_rejects_binary_file() { using TestDirectory directory = new(); string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); string binaryFile = Path.Combine(workspace, "binary.dat"); File.WriteAllBytes(binaryFile, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, ..new byte[100]]); var audit = new MemorySecurityAuditSink(); var settings = new WindowsSandboxSettings( [new FilePolicyEntry(workspace, FileCapability.Read)], [], false, new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit); Guid taskId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); using JsonDocument tail = JsonDocument.Parse($$"""{"root":{{JsonSerializer.Serialize(binaryFile)}},"mode":"tail","query":"","tail_lines":10}"""); await Assert.ThrowsAsync(async () => await backend.ExecuteAsync(taskId, "file_search", tail.RootElement, TestContext.Current.CancellationToken)); await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); } [Theory] [InlineData("utf8")] [InlineData("utf8bom")] [InlineData("utf16le")] [InlineData("utf16be")] public async Task Tail_accepts_text_files(string encoding) { using TestDirectory directory = new(); string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); string textFile = Path.Combine(workspace, "text.txt"); string content = "line1\nline2\nline3\n"; byte[] bytes = encoding switch { "utf8" => System.Text.Encoding.UTF8.GetBytes(content), "utf8bom" => [.. System.Text.Encoding.UTF8.Preamble, .. System.Text.Encoding.UTF8.GetBytes(content)], "utf16le" => [.. System.Text.Encoding.Unicode.Preamble, .. System.Text.Encoding.Unicode.GetBytes(content)], "utf16be" => [.. System.Text.Encoding.BigEndianUnicode.Preamble, .. System.Text.Encoding.BigEndianUnicode.GetBytes(content)], _ => throw new ArgumentException(encoding) }; File.WriteAllBytes(textFile, bytes); var audit = new MemorySecurityAuditSink(); var settings = new WindowsSandboxSettings( [new FilePolicyEntry(workspace, FileCapability.Read)], [], false, new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit); Guid taskId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); using JsonDocument tail = JsonDocument.Parse($$"""{"root":{{JsonSerializer.Serialize(textFile)}},"mode":"tail","query":"","tail_lines":10}"""); ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_search", tail.RootElement, TestContext.Current.CancellationToken); Assert.True(result.Success); await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); } [Fact] public async Task File_edit_rename_moves_file_within_approved_root() { using TestDirectory directory = new(); string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); string source = Path.Combine(workspace, "source.txt"); string dest = Path.Combine(workspace, "dest.txt"); File.WriteAllText(source, "rename-content"); var audit = new MemorySecurityAuditSink(); var settings = new WindowsSandboxSettings( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false, new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit); Guid taskId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}"""); ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_edit", rename.RootElement, TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.True(result.ExternalEffect); Assert.False(File.Exists(source)); Assert.True(File.Exists(dest)); Assert.Equal("rename-content", File.ReadAllText(dest)); await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals"))); } [Fact] public async Task File_edit_rename_rejects_existing_destination() { using TestDirectory directory = new(); string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); string source = Path.Combine(workspace, "source.txt"); string dest = Path.Combine(workspace, "dest.txt"); File.WriteAllText(source, "source-content"); File.WriteAllText(dest, "dest-content"); var audit = new MemorySecurityAuditSink(); var settings = new WindowsSandboxSettings( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], false, new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit); Guid taskId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}"""); await Assert.ThrowsAsync(async () => await backend.ExecuteAsync(taskId, "file_edit", rename.RootElement, TestContext.Current.CancellationToken)); Assert.True(File.Exists(source)); // source still exists Assert.True(File.Exists(dest)); // dest untouched await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); } [Fact] public async Task File_edit_rename_rejects_outside_approved_root() { using TestDirectory directory = new(); using TestDirectory outside = new(); string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); string source = Path.Combine(workspace, "source.txt"); string dest = Path.Combine(outside.Path, "escaped.txt"); File.WriteAllText(source, "content"); var audit = new MemorySecurityAuditSink(); var settings = new WindowsSandboxSettings( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], false, new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit); Guid taskId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}"""); ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_edit", rename.RootElement, TestContext.Current.CancellationToken); Assert.False(result.Success); Assert.False(result.RetryAllowed); await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); } [Fact(Timeout = 60_000)] public async Task Wfp_enforces_actual_appcontainer_loopback_deny_allow_and_port_scope() { AppContainerProfile profile = AppContainerProfile.EnsureFixed(); string curl = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "curl.exe"); var audit = new MemorySecurityAuditSink(); var launcher = new RestrictedProcessLauncher( new ExecutablePolicy([new ExecutableApproval(curl, ExecutablePolicy.ComputeSha256(curl))], true), audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024)); using TestDirectory directory = new(); bool diagnosticExemptionAdded = LoopbackExemption.Ensure(profile.Sid); try { using var isolationControl = new OneShotHttpServer(IPAddress.Loopback); RestrictedProcessResult isolationResult = Curl(launcher, Guid.NewGuid(), curl, isolationControl.Url, Environment.SystemDirectory); Assert.True(isolationResult.ExitCode == 0u, $"Loopback exemption did not enable AppContainer traffic before WFP was applied. exit {isolationResult.ExitCode}, stderr: {isolationResult.StandardError}"); Assert.True(isolationControl.Accepted); } finally { if (diagnosticExemptionAdded) LoopbackExemption.Remove(profile.Sid); } using (var deniedServer = new OneShotHttpServer(IPAddress.Loopback)) { Guid taskId = Guid.NewGuid(); TaskResourceJournal journal = TaskResourceJournal.Create(Path.Combine(directory.Path, "deny-journal"), taskId); using (TaskNetworkPolicy.Apply(journal, profile.Sid, [], audit)) { RestrictedProcessResult denied = Curl(launcher, taskId, curl, deniedServer.Url, Environment.SystemDirectory); Assert.NotEqual(0u, denied.ExitCode); Assert.False(deniedServer.Accepted); } journal.CompleteAndDelete(); } IPAddress allowedAddress = IPAddress.Loopback; using (var controlServer = new OneShotHttpServer(allowedAddress)) using (var controlClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }) { string controlBody = await controlClient.GetStringAsync(controlServer.Url, TestContext.Current.CancellationToken); Assert.Equal("WFP_OK", controlBody); Assert.True(controlServer.Accepted); } using var allowedV4 = new OneShotHttpServer(allowedAddress); using var deniedPort = new OneShotHttpServer(allowedAddress); Guid allowTaskId = Guid.NewGuid(); TaskResourceJournal allowJournal = TaskResourceJournal.Create(Path.Combine(directory.Path, "allow-journal"), allowTaskId); IReadOnlyList allowances = [ new(allowedAddress, checked((ushort)allowedV4.Port)) ]; using (TaskNetworkPolicy.Apply(allowJournal, profile.Sid, allowances, audit)) { RestrictedProcessResult v4 = Curl(launcher, allowTaskId, curl, allowedV4.Url, Environment.SystemDirectory); RestrictedProcessResult wrongPort = Curl(launcher, allowTaskId, curl, deniedPort.Url, Environment.SystemDirectory); Assert.True(v4.ExitCode == 0u, $"Allowed AppContainer curl failed with exit {v4.ExitCode}. stderr: {v4.StandardError}"); Assert.Contains("WFP_OK", v4.StandardOutput, StringComparison.Ordinal); Assert.True(allowedV4.Accepted); Assert.NotEqual(0u, wrongPort.ExitCode); Assert.False(deniedPort.Accepted); } allowJournal.CompleteAndDelete(); Assert.Contains(audit.Events, item => item.Event == "network.rule.remove" && item.Data["residual_filters"] == "0"); } private static RestrictedProcessResult Curl(RestrictedProcessLauncher launcher, Guid taskId, string curl, string url, string cwd) => launcher.Run(taskId, curl, ["--noproxy", "*", "--silent", "--show-error", "--max-time", "2", url], cwd, TimeSpan.FromSeconds(10)); private static byte[] AccessDescriptor(string path) { FileSystemSecurity security = Directory.Exists(path) ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access) : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access); return security.GetSecurityDescriptorBinaryForm(); } private static FileSystemAccessRule[] AccessRules(string path, System.Security.Principal.SecurityIdentifier sid) { FileSystemSecurity security = Directory.Exists(path) ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access) : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access); return security.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(System.Security.Principal.SecurityIdentifier)) .OfType() .Where(rule => rule.IdentityReference == sid && rule.AccessControlType == AccessControlType.Allow) .ToArray(); } private sealed class TestDirectory : IDisposable { public TestDirectory() { Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "agent-mcp-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path); } public string Path { get; } public void Dispose() => Directory.Delete(Path, recursive: true); } private sealed class OneShotHttpServer : IDisposable { private readonly TcpListener _listener; private readonly CancellationTokenSource _stop = new(); private readonly Task _serve; private int _accepted; public OneShotHttpServer(IPAddress address) { _listener = new TcpListener(address, 0); _listener.Start(); Port = ((IPEndPoint)_listener.LocalEndpoint).Port; string host = address.AddressFamily == AddressFamily.InterNetworkV6 ? $"[{address}]" : address.ToString(); Url = $"http://{host}:{Port}/"; _serve = ServeAsync(); } public int Port { get; } public string Url { get; } public bool Accepted => Volatile.Read(ref _accepted) != 0; private async Task ServeAsync() { try { using TcpClient client = await _listener.AcceptTcpClientAsync(_stop.Token); Interlocked.Exchange(ref _accepted, 1); using NetworkStream stream = client.GetStream(); byte[] request = new byte[4096]; int received = 0; while (received < request.Length) { int count = await stream.ReadAsync(request.AsMemory(received), _stop.Token); if (count == 0) break; received += count; if (received >= 4 && request.AsSpan(0, received).IndexOf("\r\n\r\n"u8) >= 0) break; } byte[] response = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\nWFP_OK"); await stream.WriteAsync(response, _stop.Token); } catch (Exception ex) when (ex is OperationCanceledException or SocketException or ObjectDisposedException) { } } public void Dispose() { _stop.Cancel(); _listener.Stop(); try { _serve.Wait(TimeSpan.FromSeconds(2)); } catch (AggregateException) { } _stop.Dispose(); } } }