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 Ports); public sealed record AppliedNetworkConfiguration(string Access, IReadOnlyList Endpoints); public sealed record AppliedResourceConfiguration(string Profile, int MemoryMb, int ProcessLimit, int ExecutionTimeoutSeconds); public sealed record AppliedConfigurationPreview( string ConfigPath, string ConfigFingerprint, IReadOnlyList Filesystem, IReadOnlyList Execution, AppliedNetworkConfiguration Network, AppliedResourceConfiguration Resources); public sealed record AppliedConfigurationDocument( int SchemaVersion, string ConfigPath, string ConfigFingerprint, IReadOnlyList Filesystem, IReadOnlyList 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(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(); 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(); 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(); var artifactRoots = new List(); 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(); var runtimeRoots = new List(); 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 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 Ports)> NetworkAllowances() => _configuration.Sandbox.Network.Access switch { "none" => [], "local" => _configuration.Sandbox.Network.LocalEndpoints .SelectMany(static item => new[] { ("127.0.0.1", (IReadOnlyList)[item.Port]), ("::1", (IReadOnlyList)[item.Port]) }).ToArray(), "restricted" => _configuration.Sandbox.Network.RestrictedEndpoints .Select(static item => (item.Host, (IReadOnlyList)item.Ports)).ToArray(), _ => throw new ConfigurationException($"Unsupported Network access '{_configuration.Sandbox.Network.Access}'.") }; private static IReadOnlyList NormalizeRoots(IEnumerable 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'."); }