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 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 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 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 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 aces = (raw.DiscretionaryAcl?.Cast() ?? []) .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() .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 _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().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 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 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.ApplyAsync(new DirectElevatedNetworkSession(), journal, scope.Profile.Sid, endpoints.Count == 0 ? "none" : "local", endpoints, scope.Audit, null, CancellationToken.None).GetAwaiter().GetResult()); } public void Dispose() { _policy.DisposeAsync().AsTask().GetAwaiter().GetResult(); _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(); } } }