summaryrefslogtreecommitdiff
path: root/tests/Agent.Windows.Tests
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-12 05:44:54 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-12 05:44:54 +0900
commitb56614c814b904c224315a11f9686f0d47abd3d3 (patch)
treee6ae4feb852ea4492bf947eabba1e11ab157686a /tests/Agent.Windows.Tests
parent98b6a2aeedd591058c1d81e401d24f3ded162926 (diff)
feat: revise sandbox usability around semantic policy and LPAC process trees
Rewrite the specification and design vocabulary around User Sandbox Configuration, Environment Resolution, Executable Approval, Effective Sandbox Policy, and Windows Enforcement. Move the Internal Tool catalog to the design without treating its count as a requirement, and update README, implementation planning, traceability, audit history, prompts, and the Python skill. Introduce schema_version 1 profiles, workspaces, mounts, executable aliases, network modes, and resource presets. Add local override merging, resolution and approval stores with atomic user-only persistence, fail-closed fingerprints, localized diagnostics, and executable/sandbox management CLI commands. Change Windows execution to LPAC entry approval with inherited descendant confinement. Separate read and execute ACLs, preserve protected OS DACLs, apply Job process/memory/timeout limits to the tree, enforce WFP network modes, prevent credential environment inheritance, disable out-of-tree MSBuild reuse, and harden transactional ACL/recovery cleanup. Isolate planning prompts from execution instructions, reject split coding plans that cannot satisfy task-scoped evidence, preserve configured thinking, and prevent optional task_update failures from corrupting completion checks. Add and update configuration, resolution, approval, LPAC, descendant, readonly/development, Registry/COM, WFP, recovery, .NET/Python/PowerShell process-tree, and Operational E2E coverage. Publish reproducible bundles while preserving deployment-local configuration, prompts, skills, logs, state, approvals, and resolutions.
Diffstat (limited to 'tests/Agent.Windows.Tests')
-rw-r--r--tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj1
-rw-r--r--tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs4
-rw-r--r--tests/Agent.Windows.Tests/SecurityPolicyTests.cs555
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)
{