diff options
Diffstat (limited to 'src/Agent.App/Sandbox/SandboxServices.cs')
| -rw-r--r-- | src/Agent.App/Sandbox/SandboxServices.cs | 333 |
1 files changed, 2 insertions, 331 deletions
diff --git a/src/Agent.App/Sandbox/SandboxServices.cs b/src/Agent.App/Sandbox/SandboxServices.cs index 93211b8..746b95b 100644 --- a/src/Agent.App/Sandbox/SandboxServices.cs +++ b/src/Agent.App/Sandbox/SandboxServices.cs @@ -1,342 +1,13 @@ -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; - } + public static bool IsSupportedShell(string path) => + Path.GetFileName(path).ToLowerInvariant() is "cmd.exe" or "powershell.exe" or "pwsh.exe"; } internal static class AtomicJson |
