summaryrefslogtreecommitdiff
path: root/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
commitb07b41cd435991cd254957af79055080c85b346e (patch)
tree27720d3ce14e4d0b84670a55ac26a2f877ce3a20 /tests/Agent.Windows.Tests/SecurityPolicyTests.cs
parent2fefd29e86c3b926d0cf4a362251d3f119688612 (diff)
feat: AI Agent Sandbox MCP Server v1を実装
Specifications、Design、ImplementationPlanの優先順位を整理し、単一Task実行モデル、typed option優先、設定可能なresource上限、local Ollamaを必須とするRelease Gateを文書化。 .NET 10/C# 14の5 production project、3 test project、E2E Clientを構築。typed YAML、en/ja locale、fixed/user prompt、stdioおよびloopback限定Streamable HTTP、agent_run/agent_status、Session、Ollama text/tool/Vision、TaskGraphとtoken accountingを実装。 Windows sandboxとしてfixed AppContainer、canonical/reparse検査、能力別ACL、path+SHA-256 executable approval、child-process restriction、Job Object、task journal、atomic file transaction、crash recoveryを実装。WFPのIPv4/IPv6 default denyとexact allow、dynamic session、temporary loopback exemption、管理者/BFE診断を追加。 Security/Recovery test、MCP/Ollama integration test、stdio/HTTP Operational E2E、Release Bundle生成・checksum・manifest・fresh extraction検査を追加。Requirement TraceabilityとPhase 0〜11の進捗、初回セットアップ可能なREADMEを整備。 Debug/Release/publish/objをrepository rootのbuild/へ集約し、旧project-local bin/objへの依存を除去。artifacts/とbuild/をGit管理外に設定し、clean checkoutでもsolution buildからE2E Clientまで生成可能にした。
Diffstat (limited to 'tests/Agent.Windows.Tests/SecurityPolicyTests.cs')
-rw-r--r--tests/Agent.Windows.Tests/SecurityPolicyTests.cs579
1 files changed, 579 insertions, 0 deletions
diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
new file mode 100644
index 0000000..d54ab13
--- /dev/null
+++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
@@ -0,0 +1,579 @@
+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 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<SandboxPolicyException>(() => 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<SandboxPolicyException>(() => 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<SandboxPolicyException>(() =>
+ 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<SandboxPolicyException>(() => new ExecutablePolicy([], true).Verify(executable));
+ string badHash = new string(hash[0] == '0' ? '1' : '0', 1) + hash[1..];
+ Assert.Throws<SandboxPolicyException>(() => 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<SandboxPolicyException>(() => 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<TimeoutException>(() => 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<ArgumentException>(() => 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<SandboxPolicyException>(() => 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 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();
+ using TestDirectory directory = new();
+ string journals = Path.Combine(directory.Path, "journals");
+ string resource = Path.Combine(directory.Path, "resource");
+ Directory.CreateDirectory(resource);
+ byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resource), AccessControlSections.Access)
+ .GetSecurityDescriptorBinaryForm();
+ 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.False(LoopbackExemption.Contains(profile.Sid));
+ Guid loopbackEntry = journal.Prepare("network.loopback_exemption", profile.Sid.Value,
+ new Dictionary<string, string> { ["originally_present"] = bool.FalseString });
+ Assert.True(LoopbackExemption.Ensure(profile.Sid));
+ journal.MarkApplied(loopbackEntry, new Dictionary<string, string> { ["added"] = bool.TrueString });
+
+ Guid wfpEntry = journal.Prepare("wfp.dynamic", profile.Sid.Value,
+ new Dictionary<string, string> { ["session"] = "dynamic", ["allowances"] = string.Empty });
+ using (WfpDynamicSession crashedSession = WfpDynamicSession.Open())
+ {
+ IReadOnlyList<ulong> filterIds = crashedSession.ApplyDefaultBlock(profile.Sid);
+ journal.MarkApplied(wfpEntry, new Dictionary<string, string> { ["filter_ids"] = string.Join(',', filterIds) });
+ // Closing the engine without RemovePolicy models abrupt process exit:
+ // BFE owns and deletes every object in this dynamic session.
+ }
+
+ 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.Empty(Directory.EnumerateFiles(journals));
+ }
+
+ [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)
+ {
+ using TestDirectory directory = new();
+ string resourceDirectory = Path.Combine(directory.Path, "resource");
+ string resourceFile = Path.Combine(directory.Path, "resource.txt");
+ Directory.CreateDirectory(resourceDirectory);
+ File.WriteAllText(resourceFile, "before-crash");
+ byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resourceDirectory), AccessControlSections.Access)
+ .GetSecurityDescriptorBinaryForm();
+ 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.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.");
+ }
+ }
+
+ 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<InvalidOperationException>(() => 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<ulong> 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();
+ await backend.BeginTaskAsync(taskId, 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 == "wfp.remove" && item.Data["residual_filters"] == "0");
+ Assert.Contains(audit.Events, item => item.Event == "acl.restore");
+ }
+
+ [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<NetworkEndpoint> 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 == "wfp.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 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();
+ }
+ }
+}