diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-13 18:20:33 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-13 18:20:33 +0900 |
| commit | 76fdcb43585a4c4d288c1026d318a0ddbe3e26ab (patch) | |
| tree | f58296832d37b035d3e787e0f270c77b59c89d77 /tests | |
| parent | 78fe349073e3a2e89f0bf14b134ae0955aa7182a (diff) | |
fix(sandbox): correct cmd descendant handling and protected ACL policy
Replace the earlier cmd-wide descendant incompatibility diagnosis with the verified distinction between drive-qualified paths and PATH/workspace-relative invocation. Document the cmd YAML configuration, executable resolution, --no-runtime approval flow, and safe descendant invocation examples in the user README.
Keep the System32 protected DACL intact for both the OS Runtime Root and approved entry files nested below it. Skip redundant file-level ACL rewrites for approved System32 executables while retaining LPAC confinement, resolved-path checks, SHA-256 approval, WFP policy, and Job Object enforcement.
Expand Windows coverage with capability positive/negative matrices, protected-DACL regression tests, cmd/PowerShell/venv Python script execution, nine Entry-to-Descendant combinations, network none/local probes, and randomized ACL round-trip testing. Add read-only CLI/README verification and published HTTP MCP agent_run verification scripts.
Update Design, Audit Report, and Requirement Traceability with the corrected root cause, current evidence, withdrawn diagnostic conclusions, and the remaining partial item for per-combination descendant self-recorded LPAC SID/Job evidence.
Verified: Release build 0 warnings/errors; Core 103 PASS; Planner 10 PASS; Windows 75 PASS with skip 0; README operational suite 19 PASS; published HTTP MCP agent_run completed 3/3 with cmd, curl, C:\tmp\.venv Python, network-none enforcement, ACL restoration, and CMD_HTTP_MCP_OK.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/Agent.Windows.Tests/ReadmeOperationalTests.cs | 446 | ||||
| -rw-r--r-- | tests/Agent.Windows.Tests/SecurityPolicyTests.cs | 77 |
2 files changed, 518 insertions, 5 deletions
diff --git a/tests/Agent.Windows.Tests/ReadmeOperationalTests.cs b/tests/Agent.Windows.Tests/ReadmeOperationalTests.cs new file mode 100644 index 0000000..fd70f06 --- /dev/null +++ b/tests/Agent.Windows.Tests/ReadmeOperationalTests.cs @@ -0,0 +1,446 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.AccessControl; +using System.Text; +using Agent.Windows.Network; +using Agent.Windows.Recovery; +using Agent.Windows.Security; + +namespace Agent.Windows.Tests; + +public sealed class ReadmeOperationalTests +{ + [Theory(Timeout = 120_000)] + [InlineData("powershell")] + [InlineData("cmd")] + [InlineData("venv-python")] + public void Readme_script_runs_in_the_configured_runtime(string executable) + { + RuntimeFixture runtime = RuntimeFixture.FromEnvironment(); + using var fixture = new TemporaryDirectory(runtime.TestRoot); + string marker = Path.Combine(fixture.Path, executable + ".txt"); + string script; + IReadOnlyList<string> arguments; + string expected; + if (executable == "powershell") + { + script = Path.Combine(fixture.Path, "probe.ps1"); + File.WriteAllText(script, $"Set-Content -LiteralPath '{marker}' -Value 'POWERSHELL_OK'", new UTF8Encoding(false)); + arguments = ["-NoLogo", "-NoProfile", "-NonInteractive", "-File", script]; + expected = "POWERSHELL_OK"; + } + else if (executable == "cmd") + { + script = Path.Combine(fixture.Path, "probe.cmd"); + File.WriteAllText(script, $"@echo off\r\n>\"{marker}\" echo CMD_OK\r\n", Encoding.ASCII); + arguments = ["/d", "/c", "call", script]; + expected = "CMD_OK"; + } + else + { + script = Path.Combine(fixture.Path, "probe.py"); + File.WriteAllText(script, + $"import pathlib,sys\npathlib.Path({PythonLiteral(marker)}).write_text('PYTHON_OK|'+sys.prefix, encoding='utf-8')\n", + new UTF8Encoding(false)); + arguments = [script]; + expected = "PYTHON_OK|"; + } + using SandboxScope scope = runtime.CreateScope(fixture.Path, allowNetwork: false); + RestrictedProcessResult result = scope.Run(executable, arguments, fixture.Path); + + Assert.True(result.ExitCode == 0u, $"{executable} exited {result.ExitCode}. stdout={result.StandardOutput}; stderr={result.StandardError}"); + string output = File.ReadAllText(marker).Trim(); + Assert.StartsWith(expected, output, StringComparison.Ordinal); + if (executable == "venv-python") + Assert.Contains(Path.GetFullPath(runtime.VenvRoot), output, StringComparison.OrdinalIgnoreCase); + } + + [Theory(Timeout = 120_000)] + [InlineData("powershell")] + [InlineData("cmd")] + [InlineData("venv-python")] + public void Network_none_blocks_powershell_cmd_and_python(string executable) + { + RuntimeFixture runtime = RuntimeFixture.FromEnvironment(); + using var fixture = new TemporaryDirectory(runtime.TestRoot); + using var deniedServer = new OneShotHttpServer(); + using SandboxScope deniedScope = runtime.CreateScope(fixture.Path, allowNetwork: false); + RestrictedProcessResult denied = RunNetworkProbe(deniedScope, executable, deniedServer.Url, fixture.Path); + Assert.DoesNotContain("not recognized as an internal or external command", denied.StandardError, StringComparison.OrdinalIgnoreCase); + Assert.NotEqual(0u, denied.ExitCode); + Assert.False(deniedServer.Accepted); + } + + [Theory(Timeout = 180_000)] + [InlineData("powershell")] + [InlineData("cmd")] + [InlineData("venv-python")] + public void Network_local_allows_only_the_configured_port(string executable) + { + RuntimeFixture runtime = RuntimeFixture.FromEnvironment(); + using var fixture = new TemporaryDirectory(runtime.TestRoot); + using var allowedServer = new OneShotHttpServer(); + using var wrongPortServer = new OneShotHttpServer(); + using (SandboxScope localScope = runtime.CreateScope(fixture.Path, allowNetwork: true)) + { + NetworkEndpoint[] endpoints = [new(IPAddress.Loopback, checked((ushort)allowedServer.Port))]; + using NetworkLease localPolicy = NetworkLease.Apply(localScope, endpoints); + RestrictedProcessResult allowed = RunNetworkProbe(localScope, executable, allowedServer.Url, fixture.Path); + RestrictedProcessResult wrongPort = RunNetworkProbe(localScope, executable, wrongPortServer.Url, fixture.Path); + Assert.True(allowed.ExitCode == 0u, + $"{executable} could not access its allowed loopback port. stdout={allowed.StandardOutput}; stderr={allowed.StandardError}"); + Assert.True(allowedServer.Accepted); + Assert.NotEqual(0u, wrongPort.ExitCode); + Assert.False(wrongPortServer.Accepted); + } + } + + [Theory(Timeout = 120_000)] + [InlineData("powershell", "cmd")] + [InlineData("powershell", "curl")] + [InlineData("powershell", "venv-python")] + [InlineData("cmd", "cmd")] + [InlineData("cmd", "curl")] + [InlineData("cmd", "venv-python")] + [InlineData("venv-python", "cmd")] + [InlineData("venv-python", "curl")] + [InlineData("venv-python", "venv-python")] + public void Lpac_descendant_compatibility_matrix(string parent, string child) + { + RuntimeFixture runtime = RuntimeFixture.FromEnvironment(); + using var fixture = new TemporaryDirectory(runtime.TestRoot); + string pythonProbe = Path.Combine(fixture.Path, "descendant-probe.py"); + File.WriteAllText(pythonProbe, "raise SystemExit(0)\n", new UTF8Encoding(false)); + using SandboxScope scope = runtime.CreateScope(fixture.Path, allowNetwork: false); + string childPath = child switch + { + "cmd" => runtime.Cmd, + "curl" => Path.Combine(Environment.SystemDirectory, "curl.exe"), + "venv-python" => runtime.Python, + _ => throw new ArgumentOutOfRangeException(nameof(child)) + }; + string[] childArguments = child switch + { + "cmd" => ["/d", "/c", "exit 0"], + "curl" => ["--version"], + "venv-python" => [pythonProbe], + _ => throw new ArgumentOutOfRangeException(nameof(child)) + }; + RestrictedProcessResult result = parent switch + { + "powershell" => scope.Run(parent, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + $"& '{childPath}' {string.Join(' ', childArguments.Select(PowerShellLiteral))}; exit $LASTEXITCODE"], fixture.Path), + "cmd" => scope.Run(parent, ["/d", "/c", child switch + { + "cmd" => "cmd.exe /d /c exit 0", + "curl" => "curl.exe --version", + "venv-python" => $"{Path.GetRelativePath(fixture.Path, runtime.Python)} {Path.GetFileName(pythonProbe)}", + _ => throw new ArgumentOutOfRangeException(nameof(child)) + }], fixture.Path), + "venv-python" => scope.Run(parent, + ["-c", $"import subprocess; raise SystemExit(subprocess.run({System.Text.Json.JsonSerializer.Serialize(new[] { childPath }.Concat(childArguments).ToArray())}).returncode)"], fixture.Path), + _ => throw new ArgumentOutOfRangeException(nameof(parent)) + }; + Assert.True(result.ExitCode == 0u, + $"LPAC descendant pair {parent}->{child} failed. stdout={result.StandardOutput}; stderr={result.StandardError}"); + } + + [Fact(Timeout = 180_000)] + public void Acl_monkey_round_trips_randomized_directory_and_file_descriptors() + { + string testRoot = RequiredDirectory("AGENT_MCP_MONKEY_ROOT"); + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + var audit = new MemorySecurityAuditSink(); + var acl = new TaskAclManager(profile.Sid, audit); + var random = new Random(0x5A17); + + for (int iteration = 0; iteration < 64; iteration++) + { + using var fixture = new TemporaryDirectory(testRoot); + string nested = Path.Combine(fixture.Path, $"d-{iteration:D2}", "nested"); + Directory.CreateDirectory(nested); + string file = Path.Combine(nested, $"f-{random.Next():X8}.txt"); + File.WriteAllText(file, "acl-monkey", new UTF8Encoding(false)); + + if ((iteration & 1) != 0) + { + DirectorySecurity security = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(fixture.Path), AccessControlSections.Access); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: true); + FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(fixture.Path), security); + } + + Dictionary<string, byte[]> before = SnapshotTree(fixture.Path); + FileCapability directoryCapability = RandomDirectoryCapability(random); + FileCapability fileCapability = RandomFileCapability(random); + Guid taskId = Guid.NewGuid(); + AclSnapshot directorySnapshot = acl.Grant(taskId, fixture.Path, directoryCapability); + AclSnapshot fileSnapshot = acl.Grant(taskId, file, fileCapability); + try + { + Assert.NotEmpty(AccessRules(fixture.Path, profile.Sid)); + Assert.NotEmpty(AccessRules(file, profile.Sid)); + } + finally + { + acl.Restore(taskId, fileSnapshot); + acl.Restore(taskId, directorySnapshot); + } + + Dictionary<string, byte[]> after = SnapshotTree(fixture.Path); + Assert.Equal(before.Keys.Order(), after.Keys.Order()); + foreach ((string path, byte[] descriptor) in before) + { + string beforeSddl = ToSddl(descriptor); + string afterSddl = ToSddl(after[path]); + Assert.DoesNotContain(profile.Sid.Value, afterSddl, StringComparison.OrdinalIgnoreCase); + if (path.Equals(fixture.Path, StringComparison.OrdinalIgnoreCase)) + Assert.True(descriptor.AsSpan().SequenceEqual(after[path]), $"Modified root descriptor did not round-trip at iteration {iteration}: {path}"); + else + Assert.Equal(AclSemantics(path, beforeSddl), AclSemantics(path, afterSddl)); + } + } + + Assert.Equal(128, audit.Events.Count(item => item.Event == "acl.apply")); + Assert.Equal(128, audit.Events.Count(item => item.Event == "acl.restore")); + } + + private static RestrictedProcessResult RunNetworkProbe(SandboxScope scope, string executable, string url, string cwd) => executable switch + { + "powershell" => scope.Run(executable, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", $"try {{ (Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri '{url}').Content | Out-Null; exit 0 }} catch {{ exit 71 }}"], cwd), + "cmd" => scope.Run(executable, + ["/d", "/c", $"curl.exe --noproxy * --silent --show-error --max-time 2 {url} >nul"], cwd), + "venv-python" => scope.Run(executable, + ["-c", $"import urllib.request; urllib.request.urlopen({PythonLiteral(url)}, timeout=2).read()"], cwd), + _ => throw new ArgumentOutOfRangeException(nameof(executable)) + }; + + private static string PythonLiteral(string value) => System.Text.Json.JsonSerializer.Serialize(value); + private static string PowerShellLiteral(string value) => "'" + value.Replace("'", "''", StringComparison.Ordinal) + "'"; + + private static FileCapability RandomDirectoryCapability(Random random) + { + FileCapability[] values = + [ + FileCapability.Read, + FileCapability.Write, + FileCapability.Read | FileCapability.Write, + FileCapability.Read | FileCapability.Execute, + FileCapability.Read | FileCapability.Write | FileCapability.Delete, + FileCapability.Read | FileCapability.Write | FileCapability.Execute | FileCapability.Delete + ]; + return values[random.Next(values.Length)]; + } + + private static FileCapability RandomFileCapability(Random random) + { + FileCapability[] values = + [ + FileCapability.Read, + FileCapability.Write, + FileCapability.Execute, + FileCapability.Read | FileCapability.Write, + FileCapability.Read | FileCapability.Execute, + FileCapability.Read | FileCapability.Write | FileCapability.Execute | FileCapability.Delete + ]; + return values[random.Next(values.Length)]; + } + + private static Dictionary<string, byte[]> SnapshotTree(string root) => + new[] { root }.Concat(Directory.EnumerateFileSystemEntries(root, "*", SearchOption.AllDirectories)) + .ToDictionary(path => path, AccessDescriptor, StringComparer.OrdinalIgnoreCase); + + private static byte[] AccessDescriptor(string path) + { + FileSystemSecurity security = Directory.Exists(path) + ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access) + : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access); + return security.GetSecurityDescriptorBinaryForm(); + } + + private static string ToSddl(byte[] descriptor) + { + var raw = new System.Security.AccessControl.RawSecurityDescriptor(descriptor, 0); + return raw.GetSddlForm(AccessControlSections.All); + } + + private static string[] AclSemantics(string path, string sddl) + { + var raw = new System.Security.AccessControl.RawSecurityDescriptor(sddl); + string flags = $"protected={raw.ControlFlags.HasFlag(ControlFlags.DiscretionaryAclProtected)}"; + IEnumerable<string> aces = (raw.DiscretionaryAcl?.Cast<System.Security.AccessControl.GenericAce>() ?? []) + .Select(ace => + { + byte[] binary = new byte[ace.BinaryLength]; + ace.GetBinaryForm(binary, 0); + return Convert.ToHexString(binary); + }) + .Order(StringComparer.Ordinal); + return [path, flags, .. aces]; + } + + private static FileSystemAccessRule[] AccessRules(string path, System.Security.Principal.SecurityIdentifier sid) + { + FileSystemSecurity security = Directory.Exists(path) + ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access) + : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access); + return security.GetAccessRules(true, true, typeof(System.Security.Principal.SecurityIdentifier)) + .OfType<FileSystemAccessRule>() + .Where(rule => rule.IdentityReference == sid && rule.AccessControlType == AccessControlType.Allow) + .ToArray(); + } + + private static string RequiredDirectory(string name) + { + string? value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value) || !Directory.Exists(value)) + throw new InvalidOperationException($"{name} must name an existing directory."); + return Path.GetFullPath(value); + } + + private sealed record RuntimeFixture(string TestRoot, string VenvRoot, string Python, string PythonRuntimeRoot, string PowerShell, string? PowerShellRuntimeRoot, string Cmd) + { + public static RuntimeFixture FromEnvironment() + { + string testRoot = RequiredDirectory("AGENT_MCP_MONKEY_ROOT"); + string python = RequiredFile("AGENT_MCP_TEST_VENV_PYTHON"); + string venvRoot = Directory.GetParent(Path.GetDirectoryName(python)!)!.FullName; + string configuration = File.ReadAllText(Path.Combine(venvRoot, "pyvenv.cfg")); + string homeLine = configuration.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Single(line => line.StartsWith("home = ", StringComparison.OrdinalIgnoreCase)); + string pythonRuntimeRoot = Path.GetFullPath(homeLine[7..].Trim()); + string powershell = RequiredFile("AGENT_MCP_TEST_POWERSHELL"); + string? powershellRuntimeRoot = Environment.GetEnvironmentVariable("AGENT_MCP_TEST_POWERSHELL_RUNTIME"); + if (!string.IsNullOrWhiteSpace(powershellRuntimeRoot)) powershellRuntimeRoot = RequiredDirectory("AGENT_MCP_TEST_POWERSHELL_RUNTIME"); + else powershellRuntimeRoot = null; + string cmd = RequiredFile("AGENT_MCP_TEST_CMD"); + return new(testRoot, venvRoot, python, pythonRuntimeRoot, powershell, powershellRuntimeRoot, cmd); + } + + public SandboxScope CreateScope(string workspace, bool allowNetwork) => new(this, workspace, allowNetwork); + + private static string RequiredFile(string name) + { + string? value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value) || !File.Exists(value)) + throw new InvalidOperationException($"{name} must name an existing file."); + return ResolvedPath.Resolve(value); + } + } + + private sealed class SandboxScope : IDisposable + { + private readonly TaskAclManager _acl; + private readonly List<AclSnapshot> _snapshots = []; + public SandboxScope(RuntimeFixture runtime, string workspace, bool allowNetwork) + { + Profile = AppContainerProfile.EnsureFixed(); + Audit = new MemorySecurityAuditSink(); + TaskId = Guid.NewGuid(); + _acl = new TaskAclManager(Profile.Sid, Audit); + _snapshots.Add(_acl.Grant(TaskId, workspace, FileCapability.Read | FileCapability.Write | FileCapability.Execute | FileCapability.Delete)); + _snapshots.Add(_acl.Grant(TaskId, runtime.VenvRoot, FileCapability.Read | FileCapability.Execute)); + _snapshots.Add(_acl.Grant(TaskId, runtime.PythonRuntimeRoot, FileCapability.Read | FileCapability.Execute)); + if (runtime.PowerShellRuntimeRoot is not null) + _snapshots.Add(_acl.Grant(TaskId, runtime.PowerShellRuntimeRoot, FileCapability.Read | FileCapability.Execute)); + ApprovedEntryExecutable[] approvals = + [ + new("powershell", runtime.PowerShell, EntryExecutablePolicy.ComputeSha256(runtime.PowerShell), true, runtime.PowerShellRuntimeRoot), + new("cmd", runtime.Cmd, EntryExecutablePolicy.ComputeSha256(runtime.Cmd), true, null), + new("venv-python", runtime.Python, EntryExecutablePolicy.ComputeSha256(runtime.Python), false, runtime.PythonRuntimeRoot) + ]; + string[] runtimeRoots = new[] { runtime.VenvRoot, runtime.PythonRuntimeRoot, runtime.PowerShellRuntimeRoot } + .OfType<string>().ToArray(); + var policy = new EntryExecutablePolicy(approvals, + runtimeRoots, [workspace]); + Launcher = new RestrictedProcessLauncher(policy, Audit, Profile.Sid, + new SandboxJobLimits(8, 1024UL * 1024 * 1024), allowNetwork); + } + + public AppContainerProfile Profile { get; } + public MemorySecurityAuditSink Audit { get; } + public Guid TaskId { get; } + public RestrictedProcessLauncher Launcher { get; } + public RestrictedProcessResult Run(string executable, IReadOnlyList<string> args, string cwd) => + Launcher.Run(TaskId, executable, args, cwd, TimeSpan.FromSeconds(15)); + + public void Dispose() + { + foreach (AclSnapshot snapshot in _snapshots.AsEnumerable().Reverse()) _acl.Restore(TaskId, snapshot); + } + } + + private sealed class NetworkLease : IDisposable + { + private readonly TaskResourceJournal _journal; + private readonly TaskNetworkPolicy _policy; + private NetworkLease(TaskResourceJournal journal, TaskNetworkPolicy policy) { _journal = journal; _policy = policy; } + public static NetworkLease Apply(SandboxScope scope, IReadOnlyList<NetworkEndpoint> endpoints) + { + string journalRoot = Path.Combine(Path.GetTempPath(), "agent-mcp-readme-network", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(journalRoot); + TaskResourceJournal journal = TaskResourceJournal.Create(journalRoot, scope.TaskId); + return new(journal, TaskNetworkPolicy.Apply(journal, scope.Profile.Sid, endpoints, scope.Audit)); + } + public void Dispose() + { + _policy.Dispose(); + _journal.CompleteAndDelete(); + string? root = Path.GetDirectoryName(_journal.Path); + if (root is not null && Directory.Exists(root)) Directory.Delete(root, true); + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory(string root) + { + Path = System.IO.Path.Combine(root, "agent-mcp-readme-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + public string Path { get; } + public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, true); } + } + + private sealed class OneShotHttpServer : IDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _stop = new(); + private readonly Task _serve; + private int _accepted; + public OneShotHttpServer() + { + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + Url = $"http://127.0.0.1:{Port}/"; + _serve = ServeAsync(); + } + public int Port { get; } + public string Url { get; } + public bool Accepted => Volatile.Read(ref _accepted) != 0; + private async Task ServeAsync() + { + try + { + using TcpClient client = await _listener.AcceptTcpClientAsync(_stop.Token); + Interlocked.Exchange(ref _accepted, 1); + using NetworkStream stream = client.GetStream(); + byte[] buffer = new byte[4096]; + _ = await stream.ReadAsync(buffer, _stop.Token); + byte[] response = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK"); + await stream.WriteAsync(response, _stop.Token); + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + catch (SocketException) when (_stop.IsCancellationRequested) { } + } + public void Dispose() + { + _stop.Cancel(); + _listener.Stop(); + try { _serve.Wait(TimeSpan.FromSeconds(2)); } catch (AggregateException) { } + _stop.Dispose(); + } + } +} diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs index 09ed61d..d0c32a8 100644 --- a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs +++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs @@ -431,8 +431,10 @@ public sealed class SecurityPolicyTests } } - [Fact] - public void Lpac_baseline_allows_registry_read_but_denies_registry_write_and_com_activation() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Lpac_capability_matrix_preserves_registry_and_com_denials(bool allowNetwork) { AppContainerProfile profile = AppContainerProfile.EnsureFixed(); using TestDirectory directory = new(); @@ -448,7 +450,7 @@ public sealed class SecurityPolicyTests { var launcher = new RestrictedProcessLauncher( new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), - audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024)); + audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024), allowNetwork); string script = $"$ErrorActionPreference='Stop'; $p=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion').ProductName; " + $"$writeDenied=$false; try {{ New-Item -Path 'HKCU:\\Software\\{registryName}' -Force | Out-Null }} catch {{ $writeDenied=$true }}; " + "$comDenied=$false; try { $null=New-Object -ComObject Scripting.FileSystemObject } catch { $comDenied=$true }; " + @@ -464,6 +466,9 @@ public sealed class SecurityPolicyTests Assert.Contains("REGISTRY_WRITE_DENIED=True", result.StandardOutput, StringComparison.Ordinal); Assert.Contains("COM_DENIED=True", result.StandardOutput, StringComparison.Ordinal); Assert.Contains("BROKER_SECRET_INHERITED=False", result.StandardOutput, StringComparison.Ordinal); + Assert.Contains(audit.Events, item => item.Event == "process.start" && + item.Data["baseline_capabilities"] == "registryRead,lpacInstrumentation" && + item.Data["network_capabilities"] == (allowNetwork ? "internetClient,privateNetworkClientServer" : "none")); } finally { @@ -507,6 +512,31 @@ public sealed class SecurityPolicyTests } [Fact] + public void Approved_cmd_starts_unapproved_descendant_by_unqualified_name() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using TestDirectory directory = new(); + string cmd = Environment.GetEnvironmentVariable("ComSpec")!; + var audit = new MemorySecurityAuditSink(); + TaskAclManager acl = new(profile.Sid, audit); + Guid taskId = Guid.NewGuid(); + AclSnapshot snapshot = acl.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write); + try + { + var launcher = new RestrictedProcessLauncher( + new EntryExecutablePolicy([new ApprovedEntryExecutable("cmd", cmd, EntryExecutablePolicy.ComputeSha256(cmd), true, null)], [], []), + audit, profile.Sid, new SandboxJobLimits(4, 512UL * 1024 * 1024)); + RestrictedProcessResult result = launcher.Run(taskId, "cmd", ["/d", "/c", "cmd.exe /d /c exit 0"], + directory.Path, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken); + Assert.Equal(0u, result.ExitCode); + Assert.Contains(audit.Events, item => item.Event == "process.start" && + item.Data["token_is_app_container"] == bool.TrueString && item.Data["process_in_job"] == bool.TrueString && + item.Data["descendant_policy"] == "lpac_inherited"); + } + finally { acl.Restore(taskId, snapshot); } + } + + [Fact] public void Approved_python314_starts_python_subprocess_inside_lpac_job() { AppContainerProfile profile = AppContainerProfile.EnsureFixed(); @@ -656,8 +686,8 @@ public sealed class SecurityPolicyTests 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. + // System32 owns an OS-managed protected DACL. Do not rewrite it; descendant + // execution relies on its existing LPAC read/execute access. var launcher = new RestrictedProcessLauncher( new EntryExecutablePolicy([new ApprovedEntryExecutable("dotnet", dotnet, EntryExecutablePolicy.ComputeSha256(dotnet), false, runtimeRoot)], [runtimeRoot], [directory.Path]), audit, profile.Sid, new SandboxJobLimits(16, 1024UL * 1024 * 1024)); @@ -975,6 +1005,43 @@ public sealed class SecurityPolicyTests } [Fact] + public async Task Os_runtime_root_protected_dacl_is_never_modified() + { + using TestDirectory directory = new(); + string system32 = ResolvedPath.Resolve(Environment.SystemDirectory); + string cmd = ResolvedPath.Resolve(Environment.GetEnvironmentVariable("ComSpec")!); + DirectorySecurity security = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(system32), AccessControlSections.Access); + var raw = new RawSecurityDescriptor(security.GetSecurityDescriptorBinaryForm(), 0); + Assert.True(raw.ControlFlags.HasFlag(ControlFlags.DiscretionaryAclProtected)); + string before = Convert.ToHexString(security.GetSecurityDescriptorBinaryForm()); + string cmdBefore = Convert.ToHexString(FileSystemAclExtensions.GetAccessControl( + new FileInfo(cmd), AccessControlSections.Access).GetSecurityDescriptorBinaryForm()); + var audit = new MemorySecurityAuditSink(); + var settings = new EffectiveSandboxPolicy( + [new FilePolicyEntry(cmd, FileCapability.Read | FileCapability.Execute, "approved_shell_executable"), + new FilePolicyEntry(system32, FileCapability.Read | FileCapability.Execute, "os_runtime_root")], + [new ApprovedEntryExecutable("cmd", cmd, EntryExecutablePolicy.ComputeSha256(cmd), true, null)], [], [], + new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), + 16 * 1024 * 1024, 1024 * 1024, 1000, 10000, BaselineCapabilities: ["registryRead", "lpacInstrumentation"]); + var backend = new WindowsToolBackend(settings, audit); + Guid taskId = Guid.NewGuid(); + + await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); + await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); + + string after = Convert.ToHexString(FileSystemAclExtensions.GetAccessControl( + new DirectoryInfo(system32), AccessControlSections.Access).GetSecurityDescriptorBinaryForm()); + string cmdAfter = Convert.ToHexString(FileSystemAclExtensions.GetAccessControl( + new FileInfo(cmd), AccessControlSections.Access).GetSecurityDescriptorBinaryForm()); + Assert.Equal(before, after); + Assert.Equal(cmdBefore, cmdAfter); + Assert.DoesNotContain(audit.Events, item => item.Event is ("acl.apply" or "acl.restore") && + item.Data.TryGetValue("path", out string? path) && + (string.Equals(path, system32, StringComparison.OrdinalIgnoreCase) || + string.Equals(path, cmd, StringComparison.OrdinalIgnoreCase))); + } + + [Fact] public async Task Dir_mkdir_creates_directory_within_approved_root_and_cleans_up() { using TestDirectory directory = new(); |
