summaryrefslogtreecommitdiff
path: root/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/Agent.Windows.Tests/SecurityPolicyTests.cs')
-rw-r--r--tests/Agent.Windows.Tests/SecurityPolicyTests.cs555
1 files changed, 477 insertions, 78 deletions
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)
{