1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
|
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.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();
}
}
}
|