diff options
Diffstat (limited to 'src/Agent.App/Sandbox')
| -rw-r--r-- | src/Agent.App/Sandbox/SandboxServices.cs | 361 |
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); + } + } +} |
