diff options
Diffstat (limited to 'tests/Agent.Windows.Tests')
| -rw-r--r-- | tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj | 1 | ||||
| -rw-r--r-- | tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs | 4 | ||||
| -rw-r--r-- | tests/Agent.Windows.Tests/SecurityPolicyTests.cs | 555 |
3 files changed, 480 insertions, 80 deletions
diff --git a/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj b/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj index 0b45401..7888f53 100644 --- a/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj +++ b/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj @@ -7,6 +7,7 @@ </PropertyGroup> <ItemGroup> <ProjectReference Include="..\..\src\Agent.Windows\Agent.Windows.csproj" /> + <ProjectReference Include="..\..\src\Agent.App\Agent.App.csproj" /> </ItemGroup> <ItemGroup> <PackageReference Include="Microsoft.NET.Test.Sdk" /> diff --git a/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs index d025eac..6686833 100644 --- a/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs +++ b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs @@ -59,7 +59,7 @@ internal static class CrashScenarioBootstrap string auditPath = Path.Combine(root, "crash-audit.jsonl"); var processAudit = new JsonLinesSecurityAuditSink(auditPath); var launcher = new RestrictedProcessLauncher( - new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), processAudit); + new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), processAudit); string escapedAudit = auditPath.Replace("'", "''", StringComparison.Ordinal); string crashError = Path.Combine(root, "crash-error.txt").Replace("'", "''", StringComparison.Ordinal); string watchdogScript = $"$d=[DateTime]::UtcNow.AddSeconds(10); while([DateTime]::UtcNow -lt $d){{ if((Test-Path -LiteralPath '{escapedAudit}') -and (Select-String -LiteralPath '{escapedAudit}' -SimpleMatch '\"event\":\"process.start\"' -Quiet)){{ Stop-Process -Id {Environment.ProcessId} -Force; exit 0 }}; Start-Sleep -Milliseconds 25 }}; Set-Content -LiteralPath '{crashError}' -Value 'Sandbox process did not start before crash'; Stop-Process -Id {Environment.ProcessId} -Force"; @@ -68,7 +68,7 @@ internal static class CrashScenarioBootstrap FileName = powershell, UseShellExecute = false, CreateNoWindow = true, ArgumentList = { "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", watchdogScript } }); - _ = launcher.Run(taskId, powershell, + _ = launcher.Run(taskId, "powershell", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30"], Environment.SystemDirectory, TimeSpan.FromMinutes(1)); break; diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs index da6b325..09ed61d 100644 --- a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs +++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs @@ -9,12 +9,36 @@ using Agent.Windows.Network; using Agent.Windows.Tools; using Agent.Core.Tools; using System.Text.Json; +using Agent.App.Sandbox; +using Agent.App.Configuration; namespace Agent.Windows.Tests; public sealed class SecurityPolicyTests { [Fact] + public void Approval_store_uses_atomic_replace_and_a_user_only_dacl() + { + using TestDirectory directory = new(); + string storePath = Path.Combine(directory.Path, "protected", "executable-approvals.json"); + var store = new ExecutableApprovalStore(storePath); + string executable = Environment.ProcessPath!; + store.Approve(new("self", "executable", executable, executable, + EntryExecutablePolicy.ComputeSha256(executable), null, DateTimeOffset.UtcNow)); + + Assert.Single(store.List()); + Assert.Empty(Directory.EnumerateFiles(Path.GetDirectoryName(storePath)!, "*.tmp")); + DirectorySecurity security = FileSystemAclExtensions.GetAccessControl( + new DirectoryInfo(Path.GetDirectoryName(storePath)!), 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<FileSystemAccessRule>().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("{}"); @@ -52,11 +76,12 @@ public sealed class SecurityPolicyTests } [Fact] - public void Reparse_escape_is_rejected() + 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")!, @@ -70,8 +95,7 @@ public sealed class SecurityPolicyTests try { - SandboxPolicyException error = Assert.Throws<SandboxPolicyException>(() => policy.IsAllowed(Path.Combine(link, "secret.txt"), FileCapability.Read)); - Assert.Equal("path.reparse", error.Code); + Assert.False(policy.IsAllowed(Path.Combine(link, "secret.txt"), FileCapability.Read)); } finally { @@ -80,68 +104,131 @@ public sealed class SecurityPolicyTests } [Fact] - public void Symlink_chain_escape_is_rejected() + 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)]); - SandboxPolicyException error = Assert.Throws<SandboxPolicyException>(() => - policy.IsAllowed(Path.Combine(first, "secret.txt"), 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<SandboxPolicyException>(() => ResolvedPath.Resolve(first)); - Assert.Equal("path.reparse", error.Code); + Assert.Equal("path.reparse_cycle", error.Code); Directory.Delete(first); Directory.Delete(second); } [Fact] - public void Executable_requires_approved_path_and_hash_and_shell_toggle() + 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: 1"); + string stores = Path.Combine(directory.Path, "stores"); + var configuration = new AgentConfiguration + { + Sandbox = new SandboxOptions { Profile = "development", Workspaces = [junctionPath] } + }; + var resolver = new SandboxEnvironmentResolver(configPath, configuration, + new SandboxResolutionStore(configPath, stores, protectDirectory: false), + new ExecutableApprovalStore(Path.Combine(stores, "approvals.json"), protectDirectory: false)); + _ = resolver.Confirm(); + Directory.Delete(junctionPath); + CreateJunction(junctionPath, secondTarget); + + ConfigurationException error = Assert.Throws<ConfigurationException>(() => resolver.Compile( + Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"))); + + Assert.Equal("sandbox.resolution_changed", 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 = 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 hash = EntryExecutablePolicy.ComputeSha256(executable); + var approval = new ApprovedEntryExecutable("test", executable, hash, false, null); + Assert.Equal(hash, new EntryExecutablePolicy([approval], [], []).VerifyEntry("test").Sha256); + Assert.Throws<SandboxPolicyException>(() => new EntryExecutablePolicy([approval], [], []).VerifyEntry(executable)); + Assert.Throws<SandboxPolicyException>(() => new EntryExecutablePolicy([], [], []).VerifyEntry(executable)); + Assert.Throws<SandboxPolicyException>(() => new EntryExecutablePolicy([], [], []) + .VerifyEntry(Path.Combine(Environment.SystemDirectory, "cmd.exe"))); 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)); + Assert.Throws<SandboxPolicyException>(() => 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 Restricted_parent_cannot_start_unbrokered_descendant() + 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 ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), audit); + 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, 4).Select(index => Path.Combine(directory.Path, $"descendant-{index}.txt")).ToArray(); + 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'; [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" + $"$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)); - Assert.NotEqual(0u, result.ExitCode); + 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.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")); + 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] @@ -150,11 +237,11 @@ public sealed class SecurityPolicyTests 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); + new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), audit); - Assert.Throws<TimeoutException>(() => launcher.Run(Guid.NewGuid(), powershell, + Assert.Throws<TimeoutException>(() => launcher.Run(Guid.NewGuid(), "powershell", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30"], - Environment.SystemDirectory, TimeSpan.FromMilliseconds(250))); + 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); @@ -162,19 +249,58 @@ public sealed class SecurityPolicyTests } [Fact] - public void Dotnet_managed_entry_requires_its_own_path_and_hash_approval() + 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<OperationCanceledException>(() => 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<ArgumentException>(() => 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 ExecutablePolicy([new ExecutableApproval(dotnet, ExecutablePolicy.ComputeSha256(dotnet))], true), audit); + new EntryExecutablePolicy([new ApprovedEntryExecutable("dotnet", dotnet, EntryExecutablePolicy.ComputeSha256(dotnet), false, null)], [], []), audit); SandboxPolicyException denied = Assert.Throws<SandboxPolicyException>(() => launcher.Run( - Guid.NewGuid(), dotnet, [managedAssembly], Path.GetDirectoryName(managedAssembly)!, TimeSpan.FromSeconds(5))); + Guid.NewGuid(), "dotnet", [managedAssembly], Path.GetDirectoryName(managedAssembly)!, TimeSpan.FromSeconds(5), + null, null, TestContext.Current.CancellationToken)); - Assert.Equal("process.path_denied", denied.Code); - Assert.Contains(audit.Events, item => item.Event == "policy.deny" && item.Data["code"] == "process.path_denied"); + 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] @@ -285,15 +411,94 @@ public sealed class SecurityPolicyTests try { var launcher = new RestrictedProcessLauncher( - new ExecutablePolicy([new ExecutableApproval(commandProcessor, ExecutablePolicy.ComputeSha256(commandProcessor))], true), + 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, commandProcessor, ["/d", "/c", $"echo APP_OK>{marker} & echo CAPTURE_OK"], directory.Path, TimeSpan.FromSeconds(30)); + 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); + 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); + } + } + + [Fact] + public void Lpac_baseline_allows_registry_read_but_denies_registry_write_and_com_activation() + { + 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)); + 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); + } + 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 { @@ -302,6 +507,178 @@ public sealed class SecurityPolicyTests } [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<AclSnapshot>(); + 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, $$""" + <Project> + <Target Name="Build"> + <WriteLinesToFile File="{{buildMarker}}" Lines="BUILD_CHILD" Overwrite="true" /> + </Target> + <Target Name="VSTest"> + <WriteLinesToFile File="{{testMarker}}" Lines="TEST_CHILD" Overwrite="true" /> + </Target> + </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<AclSnapshot>(); + 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 a protected DACL. Do not rewrite it; successful cmd descendants below + // are the enforcement check for 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(); @@ -567,9 +944,9 @@ public sealed class SecurityPolicyTests 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"), + 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); Guid taskId = Guid.NewGuid(); @@ -606,9 +983,9 @@ public sealed class SecurityPolicyTests string target = Path.Combine(workspace, "subdir"); byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(workspace), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); var audit = new MemorySecurityAuditSink(); - var settings = new WindowsSandboxSettings( - [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false, - new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), + 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); Guid taskId = Guid.NewGuid(); @@ -637,9 +1014,9 @@ public sealed class SecurityPolicyTests string target = Path.Combine(workspace, "subdir"); Directory.CreateDirectory(target); var audit = new MemorySecurityAuditSink(); - var settings = new WindowsSandboxSettings( - [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false, - new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), + 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); Guid taskId = Guid.NewGuid(); @@ -665,17 +1042,20 @@ public sealed class SecurityPolicyTests Directory.CreateDirectory(target); File.WriteAllText(Path.Combine(target, "child.txt"), "content"); var audit = new MemorySecurityAuditSink(); - var settings = new WindowsSandboxSettings( - [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false, - new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), + 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); Guid taskId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); using JsonDocument rmdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"rmdir"}"""); - await Assert.ThrowsAsync<ToolValidationException>(async () => await backend.ExecuteAsync(taskId, "dir", rmdir.RootElement, TestContext.Current.CancellationToken)); + 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); } @@ -689,9 +1069,9 @@ public sealed class SecurityPolicyTests Directory.CreateDirectory(Path.Combine(workspace, "child-dir")); File.WriteAllText(Path.Combine(workspace, "child.txt"), "content"); var audit = new MemorySecurityAuditSink(); - var settings = new WindowsSandboxSettings( - [new FilePolicyEntry(workspace, FileCapability.Read)], [], false, - new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), + 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); Guid taskId = Guid.NewGuid(); @@ -715,9 +1095,9 @@ public sealed class SecurityPolicyTests string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); var audit = new MemorySecurityAuditSink(); - var settings = new WindowsSandboxSettings( - [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], false, - new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), + 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); Guid taskId = Guid.NewGuid(); @@ -741,17 +1121,20 @@ public sealed class SecurityPolicyTests string binaryFile = Path.Combine(workspace, "binary.dat"); File.WriteAllBytes(binaryFile, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, ..new byte[100]]); var audit = new MemorySecurityAuditSink(); - var settings = new WindowsSandboxSettings( - [new FilePolicyEntry(workspace, FileCapability.Read)], [], false, - new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), + 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); Guid taskId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); using JsonDocument tail = JsonDocument.Parse($$"""{"root":{{JsonSerializer.Serialize(binaryFile)}},"mode":"tail","query":"","tail_lines":10}"""); - await Assert.ThrowsAsync<ToolValidationException>(async () => await backend.ExecuteAsync(taskId, "file_search", tail.RootElement, TestContext.Current.CancellationToken)); + 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); } @@ -777,9 +1160,9 @@ public sealed class SecurityPolicyTests }; File.WriteAllBytes(textFile, bytes); var audit = new MemorySecurityAuditSink(); - var settings = new WindowsSandboxSettings( - [new FilePolicyEntry(workspace, FileCapability.Read)], [], false, - new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), + 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); Guid taskId = Guid.NewGuid(); @@ -802,9 +1185,9 @@ public sealed class SecurityPolicyTests string dest = Path.Combine(workspace, "dest.txt"); File.WriteAllText(source, "rename-content"); var audit = new MemorySecurityAuditSink(); - var settings = new WindowsSandboxSettings( - [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false, - new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), + 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); Guid taskId = Guid.NewGuid(); @@ -833,17 +1216,20 @@ public sealed class SecurityPolicyTests File.WriteAllText(source, "source-content"); File.WriteAllText(dest, "dest-content"); var audit = new MemorySecurityAuditSink(); - var settings = new WindowsSandboxSettings( - [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], false, - new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), + 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); Guid taskId = Guid.NewGuid(); await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}"""); - await Assert.ThrowsAsync<ToolValidationException>(async () => await backend.ExecuteAsync(taskId, "file_edit", rename.RootElement, TestContext.Current.CancellationToken)); + 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); @@ -860,9 +1246,9 @@ public sealed class SecurityPolicyTests string dest = Path.Combine(outside.Path, "escaped.txt"); File.WriteAllText(source, "content"); var audit = new MemorySecurityAuditSink(); - var settings = new WindowsSandboxSettings( - [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], false, - new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), + 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); Guid taskId = Guid.NewGuid(); @@ -883,8 +1269,8 @@ public sealed class SecurityPolicyTests 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)); + 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); @@ -945,7 +1331,20 @@ public sealed class SecurityPolicyTests } 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)); + 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) { |
