summaryrefslogtreecommitdiff
path: root/src/Agent.App/Sandbox/AppliedSandboxConfiguration.cs
diff options
context:
space:
mode:
Diffstat (limited to 'src/Agent.App/Sandbox/AppliedSandboxConfiguration.cs')
-rw-r--r--src/Agent.App/Sandbox/AppliedSandboxConfiguration.cs267
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'.");
+}