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; using Agent.App.Sandbox; using Agent.App.Configuration; using LoopbackExemption = Agent.Admin.Host.LoopbackExemption; using WfpDynamicSession = Agent.Admin.Host.WfpDynamicSession; namespace Agent.Windows.Tests; public sealed class SecurityPolicyTests { [Fact] public void Applied_configuration_store_uses_atomic_replace_and_a_user_only_dacl() { using TestDirectory directory = new(); string configPath = Path.Combine(directory.Path, "agent.yaml"); File.WriteAllText(configPath, "schema_version: 2"); string storeRoot = Path.Combine(directory.Path, "protected"); var store = new AppliedConfigurationStore(configPath, storeRoot); store.Save(new(2, Path.GetFullPath(configPath), new string('A', 64), [], [], new("none", []), new("normal", 4096, 16, 1800), DateTimeOffset.UtcNow)); Assert.NotNull(store.Load()); Assert.Empty(Directory.EnumerateFiles(storeRoot, "*.tmp")); DirectorySecurity security = FileSystemAclExtensions.GetAccessControl( new DirectoryInfo(storeRoot), AccessControlSections.Access); Assert.True(security.AreAccessRulesProtected); using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); var rules = security.GetAccessRules(true, false, typeof(System.Security.Principal.SecurityIdentifier)) .OfType().ToArray(); Assert.NotEmpty(rules); Assert.All(rules, rule => Assert.Equal(identity.User, rule.IdentityReference)); } [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_resolved_and_denied_by_effective_root() { using TestDirectory approved = new(); using TestDirectory outside = new(); string link = Path.Combine(approved.Path, "escape"); File.WriteAllText(Path.Combine(outside.Path, "secret.txt"), "secret"); 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 { Assert.False(policy.IsAllowed(Path.Combine(link, "secret.txt"), FileCapability.Read)); } finally { Directory.Delete(link); } } [Fact] public void Symlink_chain_is_resolved_and_denied_by_effective_root() { using TestDirectory approved = new(); using TestDirectory outside = new(); string second = Path.Combine(approved.Path, "second-link"); string first = Path.Combine(approved.Path, "first-link"); File.WriteAllText(Path.Combine(outside.Path, "secret.txt"), "secret"); Directory.CreateSymbolicLink(second, outside.Path); Directory.CreateSymbolicLink(first, second); PathPolicy policy = new([new FilePolicyEntry(approved.Path, FileCapability.Read)]); Assert.False(policy.IsAllowed(Path.Combine(first, "secret.txt"), FileCapability.Read)); Directory.Delete(first); Directory.Delete(second); } [Fact] public void Reparse_resolution_cycle_is_rejected_fail_closed() { using TestDirectory directory = new(); string first = Path.Combine(directory.Path, "cycle-a"); string second = Path.Combine(directory.Path, "cycle-b"); Directory.CreateSymbolicLink(first, second); Directory.CreateSymbolicLink(second, first); SandboxPolicyException error = Assert.Throws(() => ResolvedPath.Resolve(first)); Assert.Equal("path.reparse_cycle", error.Code); Directory.Delete(first); Directory.Delete(second); } [Fact] public void Confirmed_resolution_is_invalidated_when_a_junction_target_changes() { using TestDirectory directory = new(); string firstTarget = Path.Combine(directory.Path, "target-one"); string secondTarget = Path.Combine(directory.Path, "target-two"); string junctionPath = Path.Combine(directory.Path, "workspace-link"); Directory.CreateDirectory(firstTarget); Directory.CreateDirectory(secondTarget); CreateJunction(junctionPath, firstTarget); string configPath = Path.Combine(directory.Path, "agent.yaml"); File.WriteAllText(configPath, "schema_version: 2"); string stores = Path.Combine(directory.Path, "stores"); var configuration = new AgentConfiguration { Sandbox = new SandboxOptions { Profile = "development", Workspaces = [junctionPath] } }; var resolver = new AppliedSandboxConfiguration(configPath, configuration, new AppliedConfigurationStore(configPath, stores, protectDirectory: false)); _ = resolver.Apply(); Directory.Delete(junctionPath); CreateJunction(junctionPath, secondTarget); ConfigurationException error = Assert.Throws(() => resolver.Compile( Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"))); Assert.Equal("sandbox.configuration_not_applied", error.Code); Directory.Delete(junctionPath); } [Fact] public void Entry_executable_requires_alias_path_and_hash_identity() { using TestDirectory roots = new(); string executable = Environment.ProcessPath!; string hash = EntryExecutablePolicy.ComputeSha256(executable); var approval = new ApprovedEntryExecutable("test", executable, hash, false, null); Assert.Equal(hash, new EntryExecutablePolicy([approval], [], []).VerifyEntry("test").Sha256); Assert.Throws(() => new EntryExecutablePolicy([approval], [], []).VerifyEntry(executable)); Assert.Throws(() => new EntryExecutablePolicy([], [], []).VerifyEntry(executable)); Assert.Throws(() => new EntryExecutablePolicy([], [], []) .VerifyEntry(Path.Combine(Environment.SystemDirectory, "cmd.exe"))); string badHash = new string(hash[0] == '0' ? '1' : '0', 1) + hash[1..]; Assert.Throws(() => new EntryExecutablePolicy([approval with { Sha256 = badHash }], [], []).VerifyEntry("test")); string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe"); var shell = new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null); Assert.Equal(EntryExecutableClassification.ApprovedShellExecutable, new EntryExecutablePolicy([shell], [], []).VerifyEntry("powershell").Classification); string runtimeRoot = Path.Combine(roots.Path, "runtime"); string artifactRoot = Path.Combine(roots.Path, "workspace"); Directory.CreateDirectory(runtimeRoot); Directory.CreateDirectory(artifactRoot); string runtimeComponent = Path.Combine(runtimeRoot, "component.exe"); string workspaceArtifact = Path.Combine(artifactRoot, "artifact.exe"); File.WriteAllText(runtimeComponent, "runtime"); File.WriteAllText(workspaceArtifact, "artifact"); var rootsPolicy = new EntryExecutablePolicy([], [runtimeRoot], [artifactRoot]); Assert.Equal(EntryExecutableClassification.RuntimeComponent, rootsPolicy.VerifyEntry(runtimeComponent).Classification); Assert.Equal(EntryExecutableClassification.WorkspaceArtifact, rootsPolicy.VerifyEntry(workspaceArtifact).Classification); Assert.Equal(EntryExecutableClassification.WorkspaceArtifact, rootsPolicy.VerifyEntry("artifact.exe", artifactRoot).Classification); } [Fact] public void Approved_entry_can_start_descendants_in_the_same_job_tree() { 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 EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), audit); string copied = Path.Combine(directory.Path, "renamed-child.exe"); File.Copy(Environment.GetEnvironmentVariable("ComSpec")!, copied); string[] markers = Enumerable.Range(1, 2).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'; Start-Process -FilePath '{copied}' -ArgumentList '/d','/c','echo compromised > \"{markers[1]}\"' -Wait" ]; foreach (string command in commands) { RestrictedProcessResult result = launcher.Run(Guid.NewGuid(), "powershell", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], directory.Path, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken); Assert.Equal(0u, result.ExitCode); } Assert.All(markers, marker => Assert.True(File.Exists(marker))); Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["descendant_policy"] == "lpac_inherited"); Assert.Equal(commands.Length, audit.Events.Count(item => item.Event == "process.exit" && item.Data["exit_code"] == "0")); } [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 EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), audit); Assert.Throws(() => launcher.Run(Guid.NewGuid(), "powershell", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30"], Environment.SystemDirectory, TimeSpan.FromMilliseconds(250), null, null, TestContext.Current.CancellationToken)); 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 Job_process_limit_applies_to_the_entire_descendant_tree() { string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); var launcher = new RestrictedProcessLauncher( new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), new MemorySecurityAuditSink(), jobLimits: new SandboxJobLimits(1, 512UL * 1024 * 1024)); const string script = "$denied=$false; try { $p=Start-Process -PassThru -ErrorAction Stop -FilePath $env:ComSpec -ArgumentList '/d','/c','exit 0'; $p.WaitForExit() } catch { $denied=$true }; if(-not $denied){ exit 46 }"; RestrictedProcessResult result = launcher.Run(Guid.NewGuid(), "powershell", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], Environment.SystemDirectory, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken); Assert.Equal(0u, result.ExitCode); } [Fact] public void Cancellation_terminates_the_entire_sandbox_job_without_orphan() { using TestDirectory directory = new(); string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); string childPidFile = Path.Combine(directory.Path, "child.pid"); var audit = new MemorySecurityAuditSink(); var launcher = new RestrictedProcessLauncher( new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), audit); using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(2)); Assert.Throws(() => launcher.Run(Guid.NewGuid(), "powershell", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", $"$p=Start-Process -PassThru -FilePath $env:ComSpec -ArgumentList '/d','/c','ping -n 30 127.0.0.1 > nul'; Set-Content -LiteralPath '{childPidFile}' -Value $p.Id; Start-Sleep -Seconds 30"], directory.Path, TimeSpan.FromSeconds(60), null, null, cancellation.Token)); Assert.True(File.Exists(childPidFile)); int childPid = int.Parse(File.ReadAllText(childPidFile), System.Globalization.CultureInfo.InvariantCulture); Assert.Throws(() => Process.GetProcessById(childPid)); Assert.Contains(audit.Events, item => item.Event == "process.cancel" && item.Outcome == "terminated"); } [Fact] public void Dotnet_managed_entry_requires_runtime_or_artifact_containment() { 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 EntryExecutablePolicy([new ApprovedEntryExecutable("dotnet", dotnet, EntryExecutablePolicy.ComputeSha256(dotnet), false, null)], [], []), audit); SandboxPolicyException denied = Assert.Throws(() => launcher.Run( Guid.NewGuid(), "dotnet", [managedAssembly], Path.GetDirectoryName(managedAssembly)!, TimeSpan.FromSeconds(5), null, null, TestContext.Current.CancellationToken)); Assert.Equal("process.managed_entry_unknown", denied.Code); Assert.Contains(audit.Events, item => item.Event == "policy.deny" && item.Data["code"] == "process.managed_entry_unknown"); } [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 EntryExecutablePolicy([new ApprovedEntryExecutable("cmd", commandProcessor, EntryExecutablePolicy.ComputeSha256(commandProcessor), true, null)], [], []), audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024)); RestrictedProcessResult result = launcher.Run(taskId, "cmd", ["/d", "/c", $"echo APP_OK>{marker} & echo CAPTURE_OK"], directory.Path, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken); 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 && item.Data["token_app_container_sid"] == profile.Sid.Value && item.Data["token_is_app_container"] == bool.TrueString && item.Data["process_in_job"] == bool.TrueString && item.Data["all_application_packages_opt_out"] == bool.TrueString); } finally { acl.Restore(taskId, snapshot); } } [Theory] [InlineData(false)] [InlineData(true)] public void Lpac_capability_matrix_preserves_registry_and_com_denials(bool allowNetwork) { AppContainerProfile profile = AppContainerProfile.EnsureFixed(); using TestDirectory directory = new(); string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe"); string registryName = "AgentSandboxMcpDenied" + Guid.NewGuid().ToString("N"); string secretName = "AGENT_SANDBOX_SECRET_" + Guid.NewGuid().ToString("N"); Environment.SetEnvironmentVariable(secretName, "must-not-cross-broker-boundary"); 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 EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024), allowNetwork); string script = $"$ErrorActionPreference='Stop'; $p=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion').ProductName; " + $"$writeDenied=$false; try {{ New-Item -Path 'HKCU:\\Software\\{registryName}' -Force | Out-Null }} catch {{ $writeDenied=$true }}; " + "$comDenied=$false; try { $null=New-Object -ComObject Scripting.FileSystemObject } catch { $comDenied=$true }; " + $"if(-not $writeDenied -or -not $comDenied -or $env:{secretName}){{ exit 43 }}; Write-Output \"REGISTRY_READ=$p;REGISTRY_WRITE_DENIED=$writeDenied;COM_DENIED=$comDenied;BROKER_SECRET_INHERITED=$([bool]$env:{secretName})\""; RestrictedProcessResult result = launcher.Run(taskId, "powershell", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], directory.Path, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken); Assert.True(result.ExitCode == 0u, $"LPAC baseline probe exited {result.ExitCode}. stdout: {result.StandardOutput}; stderr: {result.StandardError}"); Assert.Contains("REGISTRY_READ=", result.StandardOutput, StringComparison.Ordinal); Assert.Contains("REGISTRY_WRITE_DENIED=True", result.StandardOutput, StringComparison.Ordinal); Assert.Contains("COM_DENIED=True", result.StandardOutput, StringComparison.Ordinal); Assert.Contains("BROKER_SECRET_INHERITED=False", result.StandardOutput, StringComparison.Ordinal); Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["baseline_capabilities"] == "registryRead,lpacInstrumentation" && item.Data["network_capabilities"] == (allowNetwork ? "internetClient,privateNetworkClientServer" : "none")); } finally { Microsoft.Win32.Registry.CurrentUser.DeleteSubKeyTree($"Software\\{registryName}", throwOnMissingSubKey: false); Environment.SetEnvironmentVariable(secretName, null); acl.Restore(taskId, snapshot); } } [Fact] public void Approved_powershell_starts_unapproved_cmd_descendant_inside_lpac_job() { AppContainerProfile profile = AppContainerProfile.EnsureFixed(); using TestDirectory directory = new(); string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe"); string marker = Path.Combine(directory.Path, "cmd-descendant.txt"); 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 EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), audit, profile.Sid, new SandboxJobLimits(4, 512UL * 1024 * 1024)); string command = $"& $env:ComSpec /d /c exit 0; if($LASTEXITCODE -ne 0){{ exit 44 }}; Set-Content -LiteralPath '{marker}' -Value 'LPAC_CHILD'"; RestrictedProcessResult result = launcher.Run(taskId, "powershell", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], directory.Path, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken); Assert.Equal(0u, result.ExitCode); Assert.Equal("LPAC_CHILD", File.ReadAllText(marker).Trim()); Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["token_is_app_container"] == bool.TrueString && item.Data["process_in_job"] == bool.TrueString && item.Data["descendant_policy"] == "lpac_inherited"); } finally { acl.Restore(taskId, snapshot); } } [Fact] public void Approved_cmd_starts_unapproved_descendant_by_unqualified_name() { AppContainerProfile profile = AppContainerProfile.EnsureFixed(); using TestDirectory directory = new(); string cmd = 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 EntryExecutablePolicy([new ApprovedEntryExecutable("cmd", cmd, EntryExecutablePolicy.ComputeSha256(cmd), true, null)], [], []), audit, profile.Sid, new SandboxJobLimits(4, 512UL * 1024 * 1024)); RestrictedProcessResult result = launcher.Run(taskId, "cmd", ["/d", "/c", "cmd.exe /d /c exit 0"], directory.Path, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken); Assert.Equal(0u, result.ExitCode); Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["token_is_app_container"] == bool.TrueString && item.Data["process_in_job"] == bool.TrueString && item.Data["descendant_policy"] == "lpac_inherited"); } finally { acl.Restore(taskId, snapshot); } } [Fact] public void Approved_python314_starts_python_subprocess_inside_lpac_job() { AppContainerProfile profile = AppContainerProfile.EnsureFixed(); using TestDirectory directory = new(); string uvPythonRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "uv", "python"); string python = Directory.EnumerateFiles(uvPythonRoot, "python.exe", SearchOption.AllDirectories) .Where(path => path.Contains("cpython-3.14", StringComparison.OrdinalIgnoreCase)) .OrderByDescending(path => path, StringComparer.OrdinalIgnoreCase).First(); string resolvedPython = ResolvedPath.Resolve(python); string runtimeRoot = Path.GetDirectoryName(resolvedPython)!; string marker = Path.Combine(directory.Path, "python-child.txt"); string breakawayMarker = Path.Combine(directory.Path, "python-breakaway-child.txt"); var audit = new MemorySecurityAuditSink(); TaskAclManager acl = new(profile.Sid, audit); Guid taskId = Guid.NewGuid(); var snapshots = new List(); try { snapshots.Add(acl.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write)); snapshots.Add(acl.Grant(taskId, runtimeRoot, FileCapability.Read | FileCapability.Execute)); var launcher = new RestrictedProcessLauncher( new EntryExecutablePolicy([new ApprovedEntryExecutable("python", resolvedPython, EntryExecutablePolicy.ComputeSha256(resolvedPython), false, runtimeRoot)], [runtimeRoot], []), audit, profile.Sid, new SandboxJobLimits(4, 256UL * 1024 * 1024)); static string ChildProbe(string path) => "import ctypes,ctypes.wintypes as w; from pathlib import Path; " + "k=ctypes.windll.kernel32; a=ctypes.windll.advapi32; k.GetCurrentProcess.restype=w.HANDLE; " + "a.OpenProcessToken.argtypes=[w.HANDLE,w.DWORD,ctypes.POINTER(w.HANDLE)]; a.GetTokenInformation.argtypes=[w.HANDLE,w.DWORD,ctypes.c_void_p,w.DWORD,ctypes.POINTER(w.DWORD)]; " + "a.ConvertSidToStringSidW.argtypes=[ctypes.c_void_p,ctypes.POINTER(w.LPWSTR)]; k.IsProcessInJob.argtypes=[w.HANDLE,w.HANDLE,ctypes.POINTER(w.BOOL)]; k.LocalFree.argtypes=[ctypes.c_void_p]; token=w.HANDLE(); " + "ok=a.OpenProcessToken(k.GetCurrentProcess(),8,ctypes.byref(token)); " + "size=w.DWORD(); a.GetTokenInformation(token,31,None,0,ctypes.byref(size)); buf=ctypes.create_string_buffer(size.value); " + "ok=ok and a.GetTokenInformation(token,31,buf,size,ctypes.byref(size)); sid=ctypes.c_void_p.from_buffer(buf).value; " + "text=w.LPWSTR(); ok=ok and a.ConvertSidToStringSidW(sid,ctypes.byref(text)); in_job=w.BOOL(); ok=ok and k.IsProcessInJob(k.GetCurrentProcess(),None,ctypes.byref(in_job)); " + $"Path({JsonSerializer.Serialize(path)}).write_text(text.value+'|'+str(bool(in_job.value)),encoding='utf-8'); k.LocalFree(text); raise SystemExit(0 if ok else 48)"; string childCode = ChildProbe(marker); string breakawayCode = ChildProbe(breakawayMarker); string parentCode = $"import subprocess,sys; " + $"result=subprocess.run([sys.executable,'-c',{JsonSerializer.Serialize(childCode)}]); " + "denied=False; " + $"\ntry: breakaway=subprocess.run([sys.executable,'-c',{JsonSerializer.Serialize(breakawayCode)}],creationflags=0x01000000); denied=(breakaway.returncode != 0)" + "\nexcept OSError: denied=True" + $"\nif not denied and __import__('pathlib').Path({JsonSerializer.Serialize(breakawayMarker)}).exists(): denied=__import__('pathlib').Path({JsonSerializer.Serialize(breakawayMarker)}).read_text(encoding='utf-8').endswith('|True')" + "\nmemory_denied=False" + "\ntry: payload=bytearray(512*1024*1024)" + "\nexcept MemoryError: memory_denied=True" + "\nraise SystemExit(0 if result.returncode == 0 and denied and memory_denied else 45)"; RestrictedProcessResult result = launcher.Run(taskId, "python", ["-c", parentCode], directory.Path, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken); Assert.True(result.ExitCode == 0u, $"Python LPAC subprocess exited {result.ExitCode}: {result.StandardError}"); Assert.Equal(profile.Sid.Value + "|True", File.ReadAllText(marker)); Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["token_is_app_container"] == bool.TrueString && item.Data["process_in_job"] == bool.TrueString); } finally { foreach (AclSnapshot snapshot in snapshots.AsEnumerable().Reverse()) acl.Restore(taskId, snapshot); } } [Fact] public void Development_workspace_artifact_runs_as_an_entry_without_an_approval() { AppContainerProfile profile = AppContainerProfile.EnsureFixed(); using TestDirectory directory = new(); string artifact = Path.Combine(directory.Path, "workspace-artifact.exe"); string marker = Path.Combine(directory.Path, "artifact-entry.txt"); File.Copy(Environment.GetEnvironmentVariable("ComSpec")!, artifact); var audit = new MemorySecurityAuditSink(); var acl = new TaskAclManager(profile.Sid, audit); Guid taskId = Guid.NewGuid(); AclSnapshot snapshot = acl.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute); try { var launcher = new RestrictedProcessLauncher(new EntryExecutablePolicy([], [], [directory.Path]), audit, profile.Sid); RestrictedProcessResult result = launcher.Run(taskId, artifact, ["/d", "/c", $"echo ARTIFACT_OK>{marker}"], directory.Path, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken); Assert.Equal(0u, result.ExitCode); Assert.Equal("ARTIFACT_OK", File.ReadAllText(marker).Trim()); Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["entry_classification"] == "WorkspaceArtifact"); } finally { acl.Restore(taskId, snapshot); } } [Fact] public void Readonly_workspace_denies_write_delete_and_execute_inside_the_lpac_tree() { AppContainerProfile profile = AppContainerProfile.EnsureFixed(); using TestDirectory directory = new(); string data = Path.Combine(directory.Path, "data.txt"); string artifact = Path.Combine(directory.Path, "artifact.exe"); File.WriteAllText(data, "keep"); File.Copy(Environment.GetEnvironmentVariable("ComSpec")!, artifact); string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe"); var audit = new MemorySecurityAuditSink(); var acl = new TaskAclManager(profile.Sid, audit); Guid taskId = Guid.NewGuid(); AclSnapshot snapshot = acl.Grant(taskId, directory.Path, FileCapability.Read); try { var launcher = new RestrictedProcessLauncher( new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), audit, profile.Sid); string script = $"$writeDenied=$false; try {{ Set-Content -LiteralPath '{data}' -Value changed -ErrorAction Stop }} catch {{ $writeDenied=$true }}; " + $"$deleteDenied=$false; try {{ Remove-Item -LiteralPath '{data}' -Force -ErrorAction Stop }} catch {{ $deleteDenied=$true }}; " + $"$executeDenied=$false; try {{ & '{artifact}' /d /c exit 0; if($LASTEXITCODE -ne 0){{ $executeDenied=$true }} }} catch {{ $executeDenied=$true }}; " + "if(-not $writeDenied -or -not $deleteDenied -or -not $executeDenied){ exit 47 }"; RestrictedProcessResult result = launcher.Run(taskId, "powershell", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], directory.Path, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken); Assert.Equal(0u, result.ExitCode); Assert.Equal("keep", File.ReadAllText(data)); } finally { acl.Restore(taskId, snapshot); } } [Fact] public void Approved_dotnet_build_and_test_commands_create_confined_process_trees() { AppContainerProfile profile = AppContainerProfile.EnsureFixed(); using TestDirectory directory = new(); string project = Path.Combine(directory.Path, "ProcessTree.proj"); string buildMarker = Path.Combine(directory.Path, "build-child.txt"); string testMarker = Path.Combine(directory.Path, "test-child.txt"); File.WriteAllText(project, $$""" """); string dotnet = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet", "dotnet.exe"); string runtimeRoot = Path.GetDirectoryName(dotnet)!; var audit = new MemorySecurityAuditSink(); var acl = new TaskAclManager(profile.Sid, audit); Guid taskId = Guid.NewGuid(); var snapshots = new List(); try { snapshots.Add(acl.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute)); snapshots.Add(acl.Grant(taskId, runtimeRoot, FileCapability.Read | FileCapability.Execute)); // System32 owns an OS-managed protected DACL. Do not rewrite it; descendant // execution relies on its existing LPAC read/execute access. var launcher = new RestrictedProcessLauncher( new EntryExecutablePolicy([new ApprovedEntryExecutable("dotnet", dotnet, EntryExecutablePolicy.ComputeSha256(dotnet), false, runtimeRoot)], [runtimeRoot], [directory.Path]), audit, profile.Sid, new SandboxJobLimits(16, 1024UL * 1024 * 1024)); RestrictedProcessResult build = launcher.Run(taskId, "dotnet", ["build", project, "--no-restore", "-nologo"], directory.Path, TimeSpan.FromSeconds(60), null, null, TestContext.Current.CancellationToken); RestrictedProcessResult test = launcher.Run(taskId, "dotnet", ["test", project, "--no-restore", "-nologo"], directory.Path, TimeSpan.FromSeconds(60), null, null, TestContext.Current.CancellationToken); Assert.True(build.ExitCode == 0u, $"dotnet build failed. stdout: {build.StandardOutput} stderr: {build.StandardError}"); Assert.True(test.ExitCode == 0u, $"dotnet test failed. stdout: {test.StandardOutput} stderr: {test.StandardError}"); Assert.Equal("BUILD_CHILD", File.ReadAllText(buildMarker).Trim()); Assert.Equal("TEST_CHILD", File.ReadAllText(testMarker).Trim()); } finally { foreach (AclSnapshot snapshot in snapshots.AsEnumerable().Reverse()) 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, new DirectElevatedNetworkSessionFactory()).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 Agent.Admin.Host.NetworkEndpoint(IPAddress.Loopback, 11434), new Agent.Admin.Host.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_file_only_effect_does_not_apply_acl_or_network_resources() { 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 EffectiveSandboxPolicy( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [], new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var elevated = new DirectElevatedNetworkSessionFactory(); var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: elevated); 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.Equal(0, elevated.Starts); Assert.DoesNotContain(audit.Events, item => item.Event is "network.rule.apply" or "network.rule.remove"); 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.DoesNotContain(audit.Events, item => item.Event is "acl.apply" or "acl.restore"); } [Fact] public async Task Os_runtime_root_protected_dacl_is_never_modified() { using TestDirectory directory = new(); string system32 = ResolvedPath.Resolve(Environment.SystemDirectory); string cmd = ResolvedPath.Resolve(Environment.GetEnvironmentVariable("ComSpec")!); DirectorySecurity security = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(system32), AccessControlSections.Access); var raw = new RawSecurityDescriptor(security.GetSecurityDescriptorBinaryForm(), 0); Assert.True(raw.ControlFlags.HasFlag(ControlFlags.DiscretionaryAclProtected)); string before = Convert.ToHexString(security.GetSecurityDescriptorBinaryForm()); string cmdBefore = Convert.ToHexString(FileSystemAclExtensions.GetAccessControl( new FileInfo(cmd), AccessControlSections.Access).GetSecurityDescriptorBinaryForm()); var audit = new MemorySecurityAuditSink(); var settings = new EffectiveSandboxPolicy( [new FilePolicyEntry(cmd, FileCapability.Read | FileCapability.Execute, "approved_shell_executable"), new FilePolicyEntry(system32, FileCapability.Read | FileCapability.Execute, "os_runtime_root")], [new ApprovedEntryExecutable("cmd", cmd, EntryExecutablePolicy.ComputeSha256(cmd), true, null)], [], [], new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000, BaselineCapabilities: ["registryRead", "lpacInstrumentation"]); var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory()); Guid taskId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); string after = Convert.ToHexString(FileSystemAclExtensions.GetAccessControl( new DirectoryInfo(system32), AccessControlSections.Access).GetSecurityDescriptorBinaryForm()); string cmdAfter = Convert.ToHexString(FileSystemAclExtensions.GetAccessControl( new FileInfo(cmd), AccessControlSections.Access).GetSecurityDescriptorBinaryForm()); Assert.Equal(before, after); Assert.Equal(cmdBefore, cmdAfter); Assert.DoesNotContain(audit.Events, item => item.Event is ("acl.apply" or "acl.restore") && item.Data.TryGetValue("path", out string? path) && (string.Equals(path, system32, StringComparison.OrdinalIgnoreCase) || string.Equals(path, cmd, StringComparison.OrdinalIgnoreCase))); } [Fact] public async Task File_only_task_does_not_start_the_elevated_helper() { using TestDirectory directory = new(); string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); string input = Path.Combine(workspace, "input.txt"); File.WriteAllText(input, "read-only operation"); var factory = new NoOpElevatedNetworkSessionFactory(); var settings = new EffectiveSandboxPolicy( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute, "workspace")], [], [], [workspace], new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000, BaselineCapabilities: ["registryRead", "lpacInstrumentation"]); var backend = new WindowsToolBackend(settings, new MemorySecurityAuditSink(), elevatedNetwork: factory); Guid taskId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); using JsonDocument read = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(input)}},"mode":"whole"}"""); ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_read", read.RootElement, TestContext.Current.CancellationToken); await backend.EndTaskAsync(taskId, result.Success, TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.Equal(0, factory.Starts); } [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 EffectiveSandboxPolicy( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [], new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory()); 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 EffectiveSandboxPolicy( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [], new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory()); 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 EffectiveSandboxPolicy( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [], new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory()); 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.False(result.Success); Assert.True(result.RetryAllowed); Assert.Equal("tool.input_invalid", result.ErrorCode); 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 EffectiveSandboxPolicy( [new FilePolicyEntry(workspace, FileCapability.Read)], [], [], [], new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory()); 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 EffectiveSandboxPolicy( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], [], [], new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory()); 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 EffectiveSandboxPolicy( [new FilePolicyEntry(workspace, FileCapability.Read)], [], [], [], new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory()); 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}"""); ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_search", tail.RootElement, TestContext.Current.CancellationToken); Assert.False(result.Success); Assert.True(result.RetryAllowed); Assert.Equal("tool.input_invalid", result.ErrorCode); 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 EffectiveSandboxPolicy( [new FilePolicyEntry(workspace, FileCapability.Read)], [], [], [], new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory()); 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 EffectiveSandboxPolicy( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [], new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory()); 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 EffectiveSandboxPolicy( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], [], [], new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory()); 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.True(result.RetryAllowed); Assert.Equal("tool.input_invalid", result.ErrorCode); 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 EffectiveSandboxPolicy( [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], [], [], new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); var backend = new WindowsToolBackend(settings, audit, elevatedNetwork: new DirectElevatedNetworkSessionFactory()); 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 EntryExecutablePolicy([new ApprovedEntryExecutable("curl", curl, EntryExecutablePolicy.ComputeSha256(curl), false, null)], [], []), audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024), allowNetwork: true); 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); await using (await TaskNetworkPolicy.ApplyAsync(new DirectElevatedNetworkSession(), journal, profile.Sid, "none", [], audit, null, TestContext.Current.CancellationToken)) { 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)) ]; await using (await TaskNetworkPolicy.ApplyAsync(new DirectElevatedNetworkSession(), allowJournal, profile.Sid, "local", allowances, audit, null, TestContext.Current.CancellationToken)) { 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 void CreateJunction(string path, string target) { using Process process = Process.Start(new ProcessStartInfo { FileName = Environment.GetEnvironmentVariable("ComSpec")!, UseShellExecute = false, CreateNoWindow = true, ArgumentList = { "/d", "/c", "mklink", "/J", path, target } })!; process.WaitForExit(); Assert.Equal(0, process.ExitCode); } 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(); } } }