summaryrefslogtreecommitdiff
path: root/src/Agent.App/Sandbox
diff options
context:
space:
mode:
Diffstat (limited to 'src/Agent.App/Sandbox')
-rw-r--r--src/Agent.App/Sandbox/SandboxServices.cs361
1 files changed, 361 insertions, 0 deletions
diff --git a/src/Agent.App/Sandbox/SandboxServices.cs b/src/Agent.App/Sandbox/SandboxServices.cs
new file mode 100644
index 0000000..93211b8
--- /dev/null
+++ b/src/Agent.App/Sandbox/SandboxServices.cs
@@ -0,0 +1,361 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Agent.App.Configuration;
+using Agent.Windows.Security;
+using Agent.Windows.Tools;
+
+namespace Agent.App.Sandbox;
+
+public sealed record ExecutableApprovalRecord(
+ string Alias,
+ string Classification,
+ string LogicalPath,
+ string ResolvedPath,
+ string Sha256,
+ string? RuntimeRoot,
+ DateTimeOffset ApprovedAt);
+
+public sealed record ExecutableApprovalDocument(int SchemaVersion, IReadOnlyList<ExecutableApprovalRecord> Approvals);
+
+public sealed class ExecutableApprovalStore
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
+ public string StorePath { get; }
+ private readonly bool _protectDirectory;
+
+ public ExecutableApprovalStore(string? storePath = null) : this(storePath, protectDirectory: true) { }
+
+ internal ExecutableApprovalStore(string? storePath, bool protectDirectory)
+ {
+ StorePath = storePath ?? Path.Combine(UserStoreRoot(), "executable-approvals.json");
+ _protectDirectory = protectDirectory;
+ }
+
+ public IReadOnlyList<ExecutableApprovalRecord> List() => Load().Approvals;
+
+ public ExecutableApprovalRecord? Find(string alias) =>
+ Load().Approvals.SingleOrDefault(item => item.Alias.Equals(alias, StringComparison.OrdinalIgnoreCase));
+
+ public void Approve(ExecutableApprovalRecord approval)
+ {
+ List<ExecutableApprovalRecord> records = Load().Approvals
+ .Where(item => !item.Alias.Equals(approval.Alias, StringComparison.OrdinalIgnoreCase)).ToList();
+ records.Add(approval);
+ Save(new(1, records.OrderBy(item => item.Alias, StringComparer.OrdinalIgnoreCase).ToArray()));
+ }
+
+ public bool Revoke(string alias)
+ {
+ ExecutableApprovalDocument current = Load();
+ ExecutableApprovalRecord[] remaining = current.Approvals
+ .Where(item => !item.Alias.Equals(alias, StringComparison.OrdinalIgnoreCase)).ToArray();
+ if (remaining.Length == current.Approvals.Count) return false;
+ Save(new(1, remaining));
+ return true;
+ }
+
+ private ExecutableApprovalDocument Load()
+ {
+ if (!File.Exists(StorePath)) return new(1, []);
+ ExecutableApprovalDocument? document = JsonSerializer.Deserialize<ExecutableApprovalDocument>(File.ReadAllText(StorePath), JsonOptions);
+ if (document is null || document.SchemaVersion != 1)
+ throw new ConfigurationException($"Unsupported Executable Approval Store schema: '{StorePath}'.");
+ if (document.Approvals.GroupBy(item => item.Alias, StringComparer.OrdinalIgnoreCase).Any(group => group.Count() != 1))
+ throw new ConfigurationException($"Executable Approval Store contains duplicate Alias records: '{StorePath}'.");
+ foreach (ExecutableApprovalRecord item in document.Approvals)
+ {
+ if (!Regex.IsMatch(item.Alias, "^[a-z0-9][a-z0-9._-]{0,63}$", RegexOptions.CultureInvariant) ||
+ item.Classification is not ("executable" or "shell") || !Path.IsPathFullyQualified(item.ResolvedPath) ||
+ !Regex.IsMatch(item.Sha256, "^[A-Fa-f0-9]{64}$", RegexOptions.CultureInvariant) ||
+ (item.RuntimeRoot is not null && !Path.IsPathFullyQualified(item.RuntimeRoot)))
+ throw new ConfigurationException($"Executable Approval Store contains an invalid record for Alias '{item.Alias}': '{StorePath}'.");
+ }
+ return document;
+ }
+
+ private void Save(ExecutableApprovalDocument document)
+ {
+ string directory = Path.GetDirectoryName(StorePath) ?? throw new ConfigurationException("Executable Approval Store path has no directory.");
+ if (_protectDirectory) UserOnlyDirectory.Ensure(directory); else Directory.CreateDirectory(directory);
+ AtomicJson.Write(StorePath, document, JsonOptions);
+ }
+
+ internal static string UserStoreRoot() => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AgentSandboxMcp");
+}
+
+public sealed record ResolutionItem(string Kind, string LogicalPath, string ResolvedPath, string Access);
+public sealed record SandboxResolutionDocument(
+ int SchemaVersion,
+ string ConfigPath,
+ string ConfigFingerprint,
+ IReadOnlyList<ResolutionItem> Items,
+ DateTimeOffset ConfirmedAt);
+
+public sealed class SandboxResolutionStore
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
+ public string StorePath { get; }
+ private readonly bool _protectDirectory;
+
+ public SandboxResolutionStore(string configPath, string? storeRoot = null) : this(configPath, storeRoot, protectDirectory: true) { }
+
+ internal SandboxResolutionStore(string configPath, string? storeRoot, bool protectDirectory)
+ {
+ string normalized = Path.GetFullPath(configPath).ToUpperInvariant();
+ string name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))) + ".json";
+ StorePath = Path.Combine(storeRoot ?? Path.Combine(ExecutableApprovalStore.UserStoreRoot(), "environment-resolutions"), name);
+ _protectDirectory = protectDirectory;
+ }
+
+ public SandboxResolutionDocument? Load()
+ {
+ if (!File.Exists(StorePath)) return null;
+ SandboxResolutionDocument? document = JsonSerializer.Deserialize<SandboxResolutionDocument>(File.ReadAllText(StorePath), JsonOptions);
+ if (document is null || document.SchemaVersion != 1)
+ throw new ConfigurationException($"Unsupported Sandbox Environment Resolution Store schema: '{StorePath}'.");
+ if (!Path.IsPathFullyQualified(document.ConfigPath) ||
+ !Regex.IsMatch(document.ConfigFingerprint, "^[A-Fa-f0-9]{64}$", RegexOptions.CultureInvariant) ||
+ document.Items.Any(item => item.Kind is not ("workspace" or "mount") || !Path.IsPathFullyQualified(item.ResolvedPath)))
+ throw new ConfigurationException($"Sandbox Environment Resolution Store contains an invalid record: '{StorePath}'.");
+ return document;
+ }
+
+ public void Save(SandboxResolutionDocument document)
+ {
+ string directory = Path.GetDirectoryName(StorePath) ?? throw new ConfigurationException("Resolution Store path has no directory.");
+ if (_protectDirectory) UserOnlyDirectory.Ensure(directory); else Directory.CreateDirectory(directory);
+ AtomicJson.Write(StorePath, document, JsonOptions);
+ }
+}
+
+public sealed record SandboxResolutionPreview(string ConfigPath, string Fingerprint, IReadOnlyList<ResolutionItem> Items);
+
+public sealed class SandboxEnvironmentResolver
+{
+ private readonly string _configPath;
+ private readonly string _baseDirectory;
+ private readonly AgentConfiguration _configuration;
+ private readonly SandboxResolutionStore _resolutionStore;
+ private readonly ExecutableApprovalStore _approvalStore;
+
+ public SandboxEnvironmentResolver(string configPath, AgentConfiguration configuration,
+ SandboxResolutionStore? resolutionStore = null, ExecutableApprovalStore? approvalStore = null)
+ {
+ _configPath = Path.GetFullPath(configPath);
+ _baseDirectory = Path.GetDirectoryName(_configPath)!;
+ _configuration = configuration;
+ _resolutionStore = resolutionStore ?? new SandboxResolutionStore(_configPath);
+ _approvalStore = approvalStore ?? new ExecutableApprovalStore();
+ }
+
+ public SandboxResolutionPreview Resolve()
+ {
+ var items = new List<ResolutionItem>();
+ foreach (string workspace in _configuration.Sandbox.Workspaces)
+ items.Add(ResolveDirectory("workspace", workspace,
+ _configuration.Sandbox.Profile == "development" ? "read_write_delete_execute" : "read_only"));
+ foreach (MountOptions mount in _configuration.Sandbox.Mounts)
+ items.Add(ResolveDirectory("mount", mount.Path, mount.Access));
+ return new(_configPath, Fingerprint(), items);
+ }
+
+ public SandboxResolutionDocument Confirm()
+ {
+ SandboxResolutionPreview preview = Resolve();
+ var document = new SandboxResolutionDocument(1, preview.ConfigPath, preview.Fingerprint, preview.Items, DateTimeOffset.UtcNow);
+ _resolutionStore.Save(document);
+ return document;
+ }
+
+ public EffectiveSandboxPolicy Compile(string journalDirectory, string backupDirectory)
+ {
+ SandboxResolutionPreview current = Resolve();
+ SandboxResolutionDocument? confirmed = _resolutionStore.Load();
+ if (confirmed is null)
+ throw Error("sandbox.resolution_unconfirmed", "Sandbox Environment Resolution is not confirmed.", _configPath,
+ "Run 'agent-mcp sandbox resolve --config <file>' and then 'agent-mcp sandbox confirm --config <file>'.");
+ if (!confirmed.ConfigPath.Equals(current.ConfigPath, StringComparison.OrdinalIgnoreCase) ||
+ !confirmed.ConfigFingerprint.Equals(current.Fingerprint, StringComparison.Ordinal) ||
+ !Equivalent(confirmed.Items, current.Items))
+ throw Error("sandbox.resolution_changed", "Sandbox Environment Resolution changed after confirmation.", _configPath,
+ "Review 'sandbox resolve' and run 'sandbox confirm' again.");
+
+ var files = new List<FilePolicyEntry>();
+ var artifactRoots = new List<string>();
+ foreach (ResolutionItem item in current.Items)
+ {
+ FileCapability capabilities = item.Kind == "workspace"
+ ? (_configuration.Sandbox.Profile == "development"
+ ? FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute
+ : FileCapability.Read)
+ : item.Access switch
+ {
+ "read_only" => FileCapability.Read,
+ "read_write" => FileCapability.Read | FileCapability.Write,
+ "read_write_delete" => FileCapability.Read | FileCapability.Write | FileCapability.Delete,
+ _ => throw new ConfigurationException($"Unsupported Mount access '{item.Access}'.")
+ };
+ files.Add(new(item.ResolvedPath, capabilities, item.Kind));
+ if (item.Kind == "workspace" && _configuration.Sandbox.Profile == "development") artifactRoots.Add(item.ResolvedPath);
+ }
+
+ var approvals = new List<ApprovedEntryExecutable>();
+ var runtimeRoots = new List<string>();
+ foreach ((string alias, bool shell) in RequestedAliases())
+ {
+ ExecutableApprovalRecord record = _approvalStore.Find(alias) ??
+ throw Error("executable.approval_missing", $"Executable Alias '{alias}' has no approval.", alias,
+ $"Run 'agent-mcp executable resolve {alias} --config <file>' and 'executable approve'.");
+ if (shell != record.Classification.Equals("shell", StringComparison.Ordinal))
+ throw Error("executable.classification_changed", $"Executable Alias '{alias}' approval classification does not match sandbox.execution configuration.", alias,
+ "Revoke and re-approve the Executable Alias using its configured classification.");
+ if (shell && !ExecutableResolver.IsSupportedShell(record.ResolvedPath))
+ throw Error("executable.shell_unsupported", $"Executable Alias '{alias}' does not resolve to a supported Shell.", record.ResolvedPath,
+ "Select cmd.exe, powershell.exe, or pwsh.exe and explicitly approve it.");
+ string logicalExecutable = Path.IsPathFullyQualified(record.LogicalPath)
+ ? record.LogicalPath : Path.Combine(_baseDirectory, record.LogicalPath);
+ string resolved = ResolvedPath.Resolve(logicalExecutable);
+ if (!resolved.Equals(record.ResolvedPath, StringComparison.OrdinalIgnoreCase))
+ throw Error("executable.resolution_changed", $"Executable Alias '{alias}' resolution changed from '{record.ResolvedPath}' to '{resolved}'.", resolved,
+ "Review the new Resolved Path and explicitly re-approve the Executable Alias.");
+ string hash = EntryExecutablePolicy.ComputeSha256(resolved);
+ if (!hash.Equals(record.Sha256, StringComparison.OrdinalIgnoreCase))
+ throw Error("executable.hash_changed", $"Executable Alias '{alias}' SHA-256 changed.", resolved,
+ "Verify the Executable identity and explicitly re-approve it.");
+ string? runtimeRoot = record.RuntimeRoot is null ? null : ResolvedPath.Resolve(record.RuntimeRoot);
+ approvals.Add(new(alias, resolved, hash, shell, runtimeRoot));
+ files.Add(new(resolved, FileCapability.Read | FileCapability.Execute, shell ? "approved_shell_executable" : "approved_entry_executable"));
+ if (runtimeRoot is not null)
+ {
+ runtimeRoots.Add(runtimeRoot);
+ files.Add(new(runtimeRoot, FileCapability.Read | FileCapability.Execute, "runtime_root"));
+ }
+ }
+
+ string system32 = ResolvedPath.Resolve(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32"));
+ files.Add(new(system32, FileCapability.Read | FileCapability.Execute, "os_runtime_root"));
+ IReadOnlyList<FilePolicyEntry> normalizedFiles = NormalizeRoots(files);
+ (int memoryMb, int processes, int timeout) = ResourceLimits();
+ IReadOnlyList<(string Host, IReadOnlyList<int> Ports)> network = NetworkAllowances();
+ return new(normalizedFiles, approvals, runtimeRoots.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
+ artifactRoots, new((uint)processes, checked((ulong)memoryMb * 1024 * 1024)),
+ _configuration.Sandbox.Network.Access, network, journalDirectory, backupDirectory,
+ checked((long)_configuration.FileEdit.MemoryBackupLimitMb * 1024 * 1024),
+ _configuration.Limits.WholeReadBytes, _configuration.Limits.SearchMaxResults,
+ _configuration.Limits.TailMaxLines, timeout, _configuration.Sandbox.Resources.Profile,
+ ["registryRead", "lpacInstrumentation"]);
+ }
+
+ private ResolutionItem ResolveDirectory(string kind, string logicalPath, string access)
+ {
+ string absolute = Path.IsPathFullyQualified(logicalPath) ? logicalPath : Path.Combine(_baseDirectory, logicalPath);
+ string resolved = ResolvedPath.Resolve(absolute);
+ if (!Directory.Exists(resolved)) throw Error("sandbox.resolved_directory_missing", $"{kind} Resolved Path is not a directory: '{resolved}'.", resolved,
+ "Correct the Logical Path or restore the directory, then confirm the Sandbox Environment Resolution again.");
+ return new(kind, logicalPath, resolved, access);
+ }
+
+ private string Fingerprint()
+ {
+ string json = JsonSerializer.Serialize(_configuration.Sandbox, new JsonSerializerOptions(JsonSerializerDefaults.Web));
+ return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
+ }
+
+ private IEnumerable<(string Alias, bool Shell)> RequestedAliases() =>
+ _configuration.Sandbox.Execution.Executables.Select(alias => (alias, false))
+ .Concat(_configuration.Sandbox.Execution.Shells.Select(alias => (alias, true)));
+
+ private (int MemoryMb, int Processes, int Timeout) ResourceLimits()
+ {
+ (int memory, int processes, int timeout) = _configuration.Sandbox.Resources.Profile switch
+ {
+ "constrained" => (1024, 4, 300),
+ "normal" => (4096, 16, 1800),
+ "extended" => (8192, 32, 3600),
+ _ => throw new ConfigurationException($"Unsupported Resource Profile '{_configuration.Sandbox.Resources.Profile}'.")
+ };
+ return (_configuration.Sandbox.Resources.MemoryMb ?? memory,
+ _configuration.Sandbox.Resources.ProcessLimit ?? processes,
+ _configuration.Sandbox.Resources.ExecutionTimeoutSeconds ?? timeout);
+ }
+
+ private IReadOnlyList<(string Host, IReadOnlyList<int> Ports)> NetworkAllowances() =>
+ _configuration.Sandbox.Network.Access switch
+ {
+ "none" => [],
+ "local" => _configuration.Sandbox.Network.LocalEndpoints
+ .SelectMany(item => new[] { ("127.0.0.1", (IReadOnlyList<int>)[item.Port]), ("::1", (IReadOnlyList<int>)[item.Port]) }).ToArray(),
+ "restricted" => _configuration.Sandbox.Network.RestrictedEndpoints
+ .Select(item => (item.Host, (IReadOnlyList<int>)item.Ports)).ToArray(),
+ _ => throw new ConfigurationException($"Unsupported Network access '{_configuration.Sandbox.Network.Access}'.")
+ };
+
+ private static IReadOnlyList<FilePolicyEntry> NormalizeRoots(IEnumerable<FilePolicyEntry> input)
+ {
+ FilePolicyEntry[] roots = input.GroupBy(item => ResolvedPath.Resolve(item.Path), StringComparer.OrdinalIgnoreCase)
+ .Select(group => new FilePolicyEntry(group.Key,
+ group.Aggregate(FileCapability.None, (value, item) => value | item.Capabilities),
+ string.Join(',', group.Select(item => item.Origin).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)))).ToArray();
+ foreach (FilePolicyEntry parent in roots)
+ foreach (FilePolicyEntry child in roots)
+ {
+ if (ReferenceEquals(parent, child) || !IsBelow(child.Path, parent.Path)) continue;
+ if ((parent.Capabilities & child.Capabilities) != parent.Capabilities)
+ throw new ConfigurationException($"Nested Filesystem Root '{child.Path}' is narrower than inherited capabilities from '{parent.Path}' and cannot be enforced without widening it.");
+ }
+ return roots;
+ }
+
+ private static bool Equivalent(IReadOnlyList<ResolutionItem> left, IReadOnlyList<ResolutionItem> right) =>
+ left.OrderBy(Key, StringComparer.OrdinalIgnoreCase).SequenceEqual(right.OrderBy(Key, StringComparer.OrdinalIgnoreCase));
+ private static string Key(ResolutionItem item) => $"{item.Kind}|{item.LogicalPath}|{item.ResolvedPath}|{item.Access}";
+ private static bool IsBelow(string child, string parent) => child.StartsWith(parent.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
+ private static ConfigurationException Error(string code, string message, string? target, string resolution) =>
+ new(message, code: code, category: "sandbox", target: target, resolution: resolution);
+}
+
+public static class ExecutableResolver
+{
+ public static bool IsSupportedShell(string path) => Path.GetFileName(path).ToLowerInvariant() is "cmd.exe" or "powershell.exe" or "pwsh.exe";
+
+ public static IReadOnlyList<string> ResolveCandidates(string alias, string? explicitPath)
+ {
+ if (!string.IsNullOrWhiteSpace(explicitPath))
+ {
+ string resolved = ResolvedPath.Resolve(explicitPath);
+ if (!File.Exists(resolved)) throw new ConfigurationException($"Executable Resolved Path is not a file: '{resolved}'.");
+ return [resolved];
+ }
+ string fileName = Path.HasExtension(alias) ? alias : alias + ".exe";
+ string[] candidates = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)
+ .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(directory => Path.Combine(directory, fileName)).Where(File.Exists)
+ .Select(path => ResolvedPath.Resolve(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ if (candidates.Length == 0) throw new ConfigurationException($"No PATH candidate was found for Executable Alias '{alias}'. Use --path <path>.");
+ return candidates;
+ }
+}
+
+internal static class AtomicJson
+{
+ public static void Write<T>(string path, T value, JsonSerializerOptions options)
+ {
+ string temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
+ {
+ JsonSerializer.Serialize(stream, value, options);
+ stream.Flush(flushToDisk: true);
+ }
+ File.Move(temporary, path, overwrite: true);
+ }
+ finally
+ {
+ if (File.Exists(temporary)) File.Delete(temporary);
+ }
+ }
+}