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 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 List() => Load().Approvals; public ExecutableApprovalRecord? Find(string alias) => Load().Approvals.SingleOrDefault(item => item.Alias.Equals(alias, StringComparison.OrdinalIgnoreCase)); public void Approve(ExecutableApprovalRecord approval) { List 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(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 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(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 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(); 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 ' and then 'agent-mcp sandbox confirm --config '."); 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(); var artifactRoots = new List(); 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(); var runtimeRoots = new List(); 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 ' 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 normalizedFiles = NormalizeRoots(files); (int memoryMb, int processes, int timeout) = ResourceLimits(); IReadOnlyList<(string Host, IReadOnlyList 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 Ports)> NetworkAllowances() => _configuration.Sandbox.Network.Access switch { "none" => [], "local" => _configuration.Sandbox.Network.LocalEndpoints .SelectMany(item => new[] { ("127.0.0.1", (IReadOnlyList)[item.Port]), ("::1", (IReadOnlyList)[item.Port]) }).ToArray(), "restricted" => _configuration.Sandbox.Network.RestrictedEndpoints .Select(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(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 left, IReadOnlyList 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 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 ."); return candidates; } } internal static class AtomicJson { public static void Write(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); } } }