diff options
Diffstat (limited to 'src/Agent.App/Sandbox/AppliedSandboxConfiguration.cs')
| -rw-r--r-- | src/Agent.App/Sandbox/AppliedSandboxConfiguration.cs | 267 |
1 files changed, 267 insertions, 0 deletions
diff --git a/src/Agent.App/Sandbox/AppliedSandboxConfiguration.cs b/src/Agent.App/Sandbox/AppliedSandboxConfiguration.cs new file mode 100644 index 0000000..d789dc3 --- /dev/null +++ b/src/Agent.App/Sandbox/AppliedSandboxConfiguration.cs @@ -0,0 +1,267 @@ +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 AppliedExecutionEntry( + string Alias, + string Classification, + string LogicalPath, + string ResolvedPath, + string Sha256, + string? LogicalRuntimeRoot, + string? ResolvedRuntimeRoot); + +public sealed record AppliedNetworkEndpoint(string Host, IReadOnlyList<int> Ports); +public sealed record AppliedNetworkConfiguration(string Access, IReadOnlyList<AppliedNetworkEndpoint> Endpoints); +public sealed record AppliedResourceConfiguration(string Profile, int MemoryMb, int ProcessLimit, int ExecutionTimeoutSeconds); + +public sealed record AppliedConfigurationPreview( + string ConfigPath, + string ConfigFingerprint, + IReadOnlyList<ResolutionItem> Filesystem, + IReadOnlyList<AppliedExecutionEntry> Execution, + AppliedNetworkConfiguration Network, + AppliedResourceConfiguration Resources); + +public sealed record AppliedConfigurationDocument( + int SchemaVersion, + string ConfigPath, + string ConfigFingerprint, + IReadOnlyList<ResolutionItem> Filesystem, + IReadOnlyList<AppliedExecutionEntry> Execution, + AppliedNetworkConfiguration Network, + AppliedResourceConfiguration Resources, + DateTimeOffset AppliedAt); + +public sealed class AppliedConfigurationStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + private readonly bool _protectDirectory; + public string StorePath { get; } + + public AppliedConfigurationStore(string configPath, string? storeRoot = null) : this(configPath, storeRoot, true) { } + + internal AppliedConfigurationStore(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(UserStoreRoot(), "applied-configurations"), name); + _protectDirectory = protectDirectory; + } + + public AppliedConfigurationDocument? Load() + { + if (!File.Exists(StorePath)) return null; + AppliedConfigurationDocument? document = JsonSerializer.Deserialize<AppliedConfigurationDocument>(File.ReadAllText(StorePath), JsonOptions); + if (document is null || document.SchemaVersion != 2 || !Path.IsPathFullyQualified(document.ConfigPath) || + !Regex.IsMatch(document.ConfigFingerprint, "^[A-Fa-f0-9]{64}$", RegexOptions.CultureInvariant) || + document.Filesystem.Any(static item => item.Kind is not ("workspace" or "mount") || !Path.IsPathFullyQualified(item.ResolvedPath)) || + document.Execution.GroupBy(static item => item.Alias, StringComparer.OrdinalIgnoreCase).Any(static group => group.Count() != 1) || + document.Execution.Any(static item => !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.ResolvedRuntimeRoot is not null && !Path.IsPathFullyQualified(item.ResolvedRuntimeRoot))) || + document.Network is null || document.Resources is null || + document.Network.Access is not ("none" or "local" or "restricted") || + document.Network.Endpoints.Any(static item => string.IsNullOrWhiteSpace(item.Host) || item.Ports.Count == 0 || item.Ports.Any(static port => port is < 1 or > 65535)) || + document.Resources.MemoryMb <= 0 || document.Resources.ProcessLimit <= 0 || document.Resources.ExecutionTimeoutSeconds <= 0) + throw new ConfigurationException($"Applied Configuration Store contains an invalid record: '{StorePath}'."); + return document; + } + + public void Save(AppliedConfigurationDocument document) + { + string directory = Path.GetDirectoryName(StorePath) ?? throw new ConfigurationException("Applied Configuration 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 class AppliedSandboxConfiguration +{ + private readonly string _configPath; + private readonly string _baseDirectory; + private readonly AgentConfiguration _configuration; + private readonly AppliedConfigurationStore _store; + + public AppliedSandboxConfiguration(string configPath, AgentConfiguration configuration, AppliedConfigurationStore? store = null) + { + _configPath = Path.GetFullPath(configPath); + _baseDirectory = Path.GetDirectoryName(_configPath)!; + _configuration = configuration; + _store = store ?? new AppliedConfigurationStore(_configPath); + } + + public AppliedConfigurationPreview Resolve() + { + var filesystem = new List<ResolutionItem>(); + foreach (string workspace in _configuration.Sandbox.Workspaces) + filesystem.Add(ResolveDirectory("workspace", workspace, + _configuration.Sandbox.Profile == "development" ? "read_write_delete_execute" : "read_only")); + foreach (MountOptions mount in _configuration.Sandbox.Mounts) + filesystem.Add(ResolveDirectory("mount", mount.Path, mount.Access)); + + var execution = new List<AppliedExecutionEntry>(); + foreach (ExecutionEntryOptions entry in _configuration.Sandbox.Execution.Entries) + { + string logicalPath = Absolute(entry.Path); + string resolvedPath = ResolvedPath.Resolve(logicalPath); + if (!File.Exists(resolvedPath)) throw Error("sandbox.resolved_executable_missing", + $"Execution Entry '{entry.Alias}' does not resolve to an existing file: '{resolvedPath}'.", resolvedPath); + string? logicalRuntime = entry.RuntimeRoot is null ? null : Absolute(entry.RuntimeRoot); + string? resolvedRuntime = logicalRuntime is null ? null : ResolvedPath.Resolve(logicalRuntime); + if (resolvedRuntime is not null && !Directory.Exists(resolvedRuntime)) throw Error("sandbox.resolved_runtime_missing", + $"Execution Entry '{entry.Alias}' Runtime Root is not a directory: '{resolvedRuntime}'.", resolvedRuntime); + string classification = ExecutableResolver.IsSupportedShell(resolvedPath) ? "shell" : "executable"; + execution.Add(new(entry.Alias, classification, entry.Path, resolvedPath, + EntryExecutablePolicy.ComputeSha256(resolvedPath), entry.RuntimeRoot, resolvedRuntime)); + } + (int memoryMb, int processes, int timeout) = ResourceLimits(); + var network = new AppliedNetworkConfiguration(_configuration.Sandbox.Network.Access, + NetworkAllowances().Select(static item => new AppliedNetworkEndpoint(item.Host, item.Ports)).ToArray()); + return new(_configPath, Fingerprint(), filesystem, execution, network, + new(_configuration.Sandbox.Resources.Profile, memoryMb, processes, timeout)); + } + + public AppliedConfigurationDocument Apply() + { + AppliedConfigurationPreview preview = Resolve(); + var document = new AppliedConfigurationDocument(2, preview.ConfigPath, preview.ConfigFingerprint, + preview.Filesystem, preview.Execution, preview.Network, preview.Resources, DateTimeOffset.UtcNow); + _store.Save(document); + return document; + } + + public AppliedConfigurationDocument? LoadApplied() => _store.Load(); + + public bool IsApplied(AppliedConfigurationPreview current, AppliedConfigurationDocument? applied) => + applied is not null && applied.ConfigPath.Equals(current.ConfigPath, StringComparison.OrdinalIgnoreCase) && + applied.ConfigFingerprint.Equals(current.ConfigFingerprint, StringComparison.Ordinal) && + applied.Filesystem.SequenceEqual(current.Filesystem) && applied.Execution.SequenceEqual(current.Execution) && + NetworkEquivalent(applied.Network, current.Network) && applied.Resources == current.Resources; + + public EffectiveSandboxPolicy Compile(string journalDirectory, string backupDirectory) + { + AppliedConfigurationPreview current = Resolve(); + AppliedConfigurationDocument? applied = _store.Load(); + if (!IsApplied(current, applied)) + throw Error("sandbox.configuration_not_applied", "Configuration is not applied or has changed.", _configPath, + "Review the consolidated permission matrix and run 'agent-mcp apply'."); + + var files = new List<FilePolicyEntry>(); + var artifactRoots = new List<string>(); + foreach (ResolutionItem item in current.Filesystem) + { + 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 (AppliedExecutionEntry entry in current.Execution) + { + bool shell = entry.Classification == "shell"; + approvals.Add(new(entry.Alias, entry.ResolvedPath, entry.Sha256, shell, entry.ResolvedRuntimeRoot)); + files.Add(new(entry.ResolvedPath, FileCapability.Read | FileCapability.Execute, + shell ? "approved_shell_executable" : "approved_entry_executable")); + if (entry.ResolvedRuntimeRoot is not null) + { + runtimeRoots.Add(entry.ResolvedRuntimeRoot); + files.Add(new(entry.ResolvedRuntimeRoot, 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(); + return new(normalizedFiles, approvals, runtimeRoots.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), artifactRoots, + new((uint)processes, checked((ulong)memoryMb * 1024 * 1024)), _configuration.Sandbox.Network.Access, + NetworkAllowances(), 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 resolved = ResolvedPath.Resolve(Absolute(logicalPath)); + if (!Directory.Exists(resolved)) throw Error("sandbox.resolved_directory_missing", + $"{kind} Resolved Path is not a directory: '{resolved}'.", resolved); + return new(kind, logicalPath, resolved, access); + } + + private string Absolute(string path) => Path.IsPathFullyQualified(path) ? Path.GetFullPath(path) : Path.GetFullPath(path, _baseDirectory); + private string Fingerprint() => Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(_configuration))); + + 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(static item => new[] { ("127.0.0.1", (IReadOnlyList<int>)[item.Port]), ("::1", (IReadOnlyList<int>)[item.Port]) }).ToArray(), + "restricted" => _configuration.Sandbox.Network.RestrictedEndpoints + .Select(static 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(static item => ResolvedPath.Resolve(item.Path), StringComparer.OrdinalIgnoreCase) + .Select(static group => new FilePolicyEntry(group.Key, + group.Aggregate(FileCapability.None, static (value, item) => value | item.Capabilities), + string.Join(',', group.Select(static 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) && + (parent.Capabilities & child.Capabilities) != parent.Capabilities && !IsDerivedExecutionRoot(child)) + 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 IsDerivedExecutionRoot(FilePolicyEntry entry) => entry.Origin.Split(',').All(static origin => + origin is "approved_entry_executable" or "approved_shell_executable" or "runtime_root" or "os_runtime_root"); + + private static bool IsBelow(string child, string parent) => child.StartsWith( + parent.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + + internal static bool NetworkEquivalent(AppliedNetworkConfiguration left, AppliedNetworkConfiguration right) => + left.Access == right.Access && left.Endpoints.Count == right.Endpoints.Count && + left.Endpoints.Zip(right.Endpoints).All(static pair => pair.First.Host == pair.Second.Host && pair.First.Ports.SequenceEqual(pair.Second.Ports)); + + private static ConfigurationException Error(string code, string message, string? target, string? resolution = null) => + new(message, code: code, category: "sandbox", target: target, resolution: resolution ?? "Correct the YAML and run 'agent-mcp apply'."); +} |
