From b56614c814b904c224315a11f9686f0d47abd3d3 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Wed, 12 Aug 2026 05:44:54 +0900 Subject: feat: revise sandbox usability around semantic policy and LPAC process trees Rewrite the specification and design vocabulary around User Sandbox Configuration, Environment Resolution, Executable Approval, Effective Sandbox Policy, and Windows Enforcement. Move the Internal Tool catalog to the design without treating its count as a requirement, and update README, implementation planning, traceability, audit history, prompts, and the Python skill. Introduce schema_version 1 profiles, workspaces, mounts, executable aliases, network modes, and resource presets. Add local override merging, resolution and approval stores with atomic user-only persistence, fail-closed fingerprints, localized diagnostics, and executable/sandbox management CLI commands. Change Windows execution to LPAC entry approval with inherited descendant confinement. Separate read and execute ACLs, preserve protected OS DACLs, apply Job process/memory/timeout limits to the tree, enforce WFP network modes, prevent credential environment inheritance, disable out-of-tree MSBuild reuse, and harden transactional ACL/recovery cleanup. Isolate planning prompts from execution instructions, reject split coding plans that cannot satisfy task-scoped evidence, preserve configured thinking, and prevent optional task_update failures from corrupting completion checks. Add and update configuration, resolution, approval, LPAC, descendant, readonly/development, Registry/COM, WFP, recovery, .NET/Python/PowerShell process-tree, and Operational E2E coverage. Publish reproducible bundles while preserving deployment-local configuration, prompts, skills, logs, state, approvals, and resolutions. --- src/Agent.App/Sandbox/SandboxServices.cs | 361 +++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 src/Agent.App/Sandbox/SandboxServices.cs (limited to 'src/Agent.App/Sandbox/SandboxServices.cs') 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 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); + } + } +} -- cgit v1.2.3