diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-13 19:15:01 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-13 19:15:01 +0900 |
| commit | 36ae1a4cba791ec633a81b38b1d8345af8487276 (patch) | |
| tree | 91cef96efac7eb8c2754658ce5af5181b1a15898 /src/Agent.App/Sandbox | |
| parent | 76fdcb43585a4c4d288c1026d318a0ddbe3e26ab (diff) | |
feat!: unify sandbox configuration and permission application
Replace the split executable approval and sandbox resolution workflows with schema_version 2 and a YAML-driven execution.entries model. Resolve workspace, mount, executable, runtime, network, and resource settings into one config-scoped Applied Configuration snapshot protected by a user-only DACL and written with atomic replacement.
Reduce the public CLI to implicit server startup, show, and apply. Remove serve/check, executable and sandbox subcommands, and configuration override flags including --config, --lang, --transport, --path, and --runtime-root. Add migration diagnostics for every removed schema and command surface, and keep server preflight mandatory for both stdio and HTTP.
Update effective-policy compilation to validate the applied fingerprint, resolved paths, and executable hashes before startup. Preserve fail-closed behavior while allowing execution entries and runtime roots inside a development workspace to inherit that workspace capability without creating an impossible narrower ACL boundary.
Rewrite the README around the consolidated permission matrix and document configuration, application, store location, startup, transport-specific elevation, Python 3.14, PowerShell, and cmd usage. Align specifications, design, implementation plan, traceability, audit report, localization, publish scripts, and operational verification with the breaking schema.
Verification: Release build completed with zero warnings and errors; Agent.Core.Tests 104/104 and Agent.Planner.Tests 10/10 passed; administrator Windows Security/Recovery 56/56 and README operational 19/19 passed with no skips; published HTTP MCP completed a cmd/curl/venv-Python task with network deny and zero ACL, WFP, journal, backup, or process residue.
Diffstat (limited to 'src/Agent.App/Sandbox')
| -rw-r--r-- | src/Agent.App/Sandbox/AppliedSandboxConfiguration.cs | 267 | ||||
| -rw-r--r-- | src/Agent.App/Sandbox/SandboxServices.cs | 333 |
2 files changed, 269 insertions, 331 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'."); +} 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 |
