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 | |
| 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')
| -rw-r--r-- | src/Agent.App/Cli/CommandLine.cs | 110 | ||||
| -rw-r--r-- | src/Agent.App/Configuration/AgentConfiguration.cs | 11 | ||||
| -rw-r--r-- | src/Agent.App/Configuration/ConfigurationLoader.cs | 13 | ||||
| -rw-r--r-- | src/Agent.App/Program.cs | 247 | ||||
| -rw-r--r-- | src/Agent.App/Sandbox/AppliedSandboxConfiguration.cs | 267 | ||||
| -rw-r--r-- | src/Agent.App/Sandbox/SandboxServices.cs | 333 |
6 files changed, 389 insertions, 592 deletions
diff --git a/src/Agent.App/Cli/CommandLine.cs b/src/Agent.App/Cli/CommandLine.cs index 3b3fde1..b2364f4 100644 --- a/src/Agent.App/Cli/CommandLine.cs +++ b/src/Agent.App/Cli/CommandLine.cs @@ -2,103 +2,47 @@ namespace Agent.App.Cli; public enum AppCommand { - Serve, - Check, - ExecutableResolve, - ExecutableApprove, - ExecutableList, - ExecutableRevoke, - SandboxResolve, - SandboxConfirm, - SandboxShow + Start, + Apply, + Show } public sealed record CommandLine( AppCommand Command, string? ConfigPath, - string? Language, - string? Transport, - string? Alias = null, - string? ExecutablePath = null, - string? RuntimeRoot = null, - bool NoRuntime = false, - bool Yes = false, - string Format = "text") + bool Yes = false) { + private static readonly HashSet<string> RemovedCommands = new(StringComparer.Ordinal) + { + "serve", "check", "executable", "sandbox" + }; + public static CommandLine Parse(string[] args) { - bool implicitServe = args.Length == 0 || args[0].StartsWith("--", StringComparison.Ordinal); - int index = implicitServe ? 0 : 1; - AppCommand command; - string? alias = null; - if (implicitServe) command = AppCommand.Serve; - else if (args[0] == "serve") command = AppCommand.Serve; - else if (args[0] == "check") command = AppCommand.Check; - else if (args[0] is "executable" or "sandbox") - { - if (index >= args.Length) throw new CommandLineException($"Expected a subcommand after '{args[0]}'."); - string subcommand = args[index++]; - command = (args[0], subcommand) switch - { - ("executable", "resolve") => AppCommand.ExecutableResolve, - ("executable", "approve") => AppCommand.ExecutableApprove, - ("executable", "list") => AppCommand.ExecutableList, - ("executable", "revoke") => AppCommand.ExecutableRevoke, - ("sandbox", "resolve") => AppCommand.SandboxResolve, - ("sandbox", "confirm") => AppCommand.SandboxConfirm, - ("sandbox", "show") => AppCommand.SandboxShow, - _ => throw new CommandLineException($"Unknown subcommand '{args[0]} {subcommand}'.") - }; - if (command is AppCommand.ExecutableResolve or AppCommand.ExecutableApprove or AppCommand.ExecutableRevoke) - { - if (index >= args.Length || args[index].StartsWith("--", StringComparison.Ordinal)) - throw new CommandLineException($"Executable Alias is required for '{args[0]} {subcommand}'."); - alias = args[index++]; - } - } - else throw new CommandLineException("Expected 'serve', 'check', 'executable', or 'sandbox'."); + if (args.Length == 0) return new(AppCommand.Start, null); + if (RemovedCommands.Contains(args[0])) + throw new CommandLineException($"'{args[0]}' was removed in schema_version 2. Use 'agent-mcp', 'agent-mcp apply', or 'agent-mcp show'."); + if (args.Any(static value => value is "--config" or "--lang" or "--transport" or "--path" or "--runtime-root" or "--no-runtime" or "--format")) + throw new CommandLineException("Configuration override options were removed in schema_version 2. Put values in YAML and pass the config path as a positional argument."); + AppCommand command = args[0] switch + { + "apply" => AppCommand.Apply, + "show" => AppCommand.Show, + _ when !args[0].StartsWith("--", StringComparison.Ordinal) => AppCommand.Start, + _ => throw new CommandLineException($"Unknown option '{args[0]}'.") + }; + int index = command == AppCommand.Start ? 0 : 1; string? config = null; - string? language = null; - string? transport = null; - string? executablePath = null; - string? runtimeRoot = null; - bool noRuntime = false; bool yes = false; - string format = "text"; while (index < args.Length) { - string option = args[index++]; - switch (option) - { - case "--yes" when !yes: yes = true; break; - case "--no-runtime" when !noRuntime && runtimeRoot is null: noRuntime = true; break; - case "--config" when config is null: config = Value(option); break; - case "--lang" when language is null: language = Value(option); break; - case "--transport" when command == AppCommand.Serve && transport is null: - transport = Value(option); - if (transport is not ("stdio" or "http")) throw new CommandLineException("Transport must be stdio or http."); - break; - case "--path" when executablePath is null: executablePath = Value(option); break; - case "--runtime-root" when runtimeRoot is null && !noRuntime: runtimeRoot = Value(option); break; - case "--format" when command == AppCommand.SandboxShow: - format = Value(option); - if (format is not ("text" or "json")) throw new CommandLineException("Format must be text or json."); - break; - default: throw new CommandLineException($"Unknown, duplicate, or invalid option '{option}' for this command."); - } - } - bool management = command is not (AppCommand.Serve or AppCommand.Check); - if (management && config is null) throw new CommandLineException("--config <file> is required for executable and sandbox commands."); - if (yes && command is not (AppCommand.ExecutableApprove or AppCommand.ExecutableRevoke or AppCommand.SandboxConfirm)) - throw new CommandLineException("--yes is valid only for approve, revoke, and confirm."); - return new(command, config, language, transport, alias, executablePath, runtimeRoot, noRuntime, yes, format); - - string Value(string optionName) - { - if (index >= args.Length) throw new CommandLineException($"Missing value for '{optionName}'."); - return args[index++]; + string value = args[index++]; + if (value == "--yes" && command == AppCommand.Apply && !yes) yes = true; + else if (!value.StartsWith("--", StringComparison.Ordinal) && config is null) config = value; + else throw new CommandLineException($"Unknown, duplicate, or invalid argument '{value}' for this command."); } + return new(command, config, yes); } } diff --git a/src/Agent.App/Configuration/AgentConfiguration.cs b/src/Agent.App/Configuration/AgentConfiguration.cs index b2f94a7..ef2eebc 100644 --- a/src/Agent.App/Configuration/AgentConfiguration.cs +++ b/src/Agent.App/Configuration/AgentConfiguration.cs @@ -74,7 +74,16 @@ public sealed class SandboxOptions public ResourceOptions Resources { get; set; } = new(); } public sealed class MountOptions { public string Path { get; set; } = string.Empty; public string Access { get; set; } = "read_only"; } -public sealed class ExecutionOptions { public List<string> Executables { get; set; } = []; public List<string> Shells { get; set; } = []; } +public sealed class ExecutionOptions +{ + public List<ExecutionEntryOptions> Entries { get; set; } = []; +} +public sealed class ExecutionEntryOptions +{ + public string Alias { get; set; } = string.Empty; + public string Path { get; set; } = string.Empty; + public string? RuntimeRoot { get; set; } +} public sealed class NetworkOptions { public string Access { get; set; } = "none"; diff --git a/src/Agent.App/Configuration/ConfigurationLoader.cs b/src/Agent.App/Configuration/ConfigurationLoader.cs index 6e15904..122c499 100644 --- a/src/Agent.App/Configuration/ConfigurationLoader.cs +++ b/src/Agent.App/Configuration/ConfigurationLoader.cs @@ -43,7 +43,7 @@ public static partial class ConfigurationLoader public static void Validate(AgentConfiguration value, string baseDirectory) { - if (value.SchemaVersion != 1) Throw("schema_version must be 1."); + if (value.SchemaVersion != 2) Throw("schema_version must be 2. Version 1 is no longer supported; migrate sandbox.execution to entries and run 'agent-mcp apply'."); if (string.IsNullOrWhiteSpace(value.Application.Language)) Throw("application.language is required."); if (value.Mcp.Transport is not ("stdio" or "http")) Throw("mcp.transport must be stdio or http."); if (!IPAddress.TryParse(value.Mcp.Http.Host, out IPAddress? host) || !IPAddress.IsLoopback(host)) Throw("mcp.http.host must be 127.0.0.1 or ::1."); @@ -95,10 +95,12 @@ public static partial class ConfigurationLoader if (sandbox.Profile == "readonly" && mount.Access != "read_only") Throw("readonly sandbox.profile permits only read_only mounts."); } - string[] aliases = sandbox.Execution.Executables.Concat(sandbox.Execution.Shells).ToArray(); + string[] aliases = sandbox.Execution.Entries.Select(static entry => entry.Alias).ToArray(); if (aliases.Any(alias => !AliasRegex().IsMatch(alias))) Throw("Executable Alias must match ^[a-z0-9][a-z0-9._-]{0,63}$."); if (aliases.Distinct(StringComparer.OrdinalIgnoreCase).Count() != aliases.Length) - Throw("Executable Alias values must be unique across sandbox.execution.executables and shells."); + Throw("sandbox.execution.entries Alias values must be unique."); + if (sandbox.Execution.Entries.Any(static entry => string.IsNullOrWhiteSpace(entry.Path))) + Throw("sandbox.execution.entries requires path for every Alias."); if (sandbox.Network.Access is not ("none" or "local" or "restricted")) Throw("sandbox.network.access must be none, local, or restricted."); @@ -125,7 +127,10 @@ public static partial class ConfigurationLoader YamlNode root = LoadRoot(yaml, "merged configuration"); if (root is not YamlMappingNode rootMap || !TryGet(rootMap, "sandbox", out YamlNode? sandboxNode) || sandboxNode is not YamlMappingNode sandbox) return; if (TryGet(sandbox, "filesystem", out _) || TryGet(sandbox, "process", out _)) - Throw("Legacy sandbox.filesystem/sandbox.process configuration is no longer supported. Migrate to sandbox.profile, workspaces, mounts, execution, network, and resources; then run 'sandbox resolve' and 'sandbox confirm'."); + Throw("Legacy sandbox.filesystem/sandbox.process configuration is no longer supported. Migrate to schema_version 2 and sandbox.execution.entries; then run 'agent-mcp apply'."); + if (TryGet(sandbox, "execution", out YamlNode? executionNode) && executionNode is YamlMappingNode execution && + (TryGet(execution, "executables", out _) || TryGet(execution, "shells", out _))) + Throw("schema_version 1 sandbox.execution.executables/shells is no longer supported. Migrate each Alias to sandbox.execution.entries with path and optional runtime_root; then run 'agent-mcp apply'."); } private static bool TryGet(YamlMappingNode map, string name, out YamlNode? value) diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs index b58c914..7372a13 100644 --- a/src/Agent.App/Program.cs +++ b/src/Agent.App/Program.cs @@ -12,7 +12,6 @@ using Agent.Core.Tools; using Agent.Windows.Recovery; using Agent.Windows.Security; using Agent.Windows.Tools; -using System.Text.Json; namespace Agent.App; @@ -27,40 +26,22 @@ internal static class Program ? Path.Combine(AppContext.BaseDirectory, "agent.yaml") : Path.GetFullPath(commandLine.ConfigPath); AgentConfiguration configuration = ConfigurationLoader.Load(configPath); - if (commandLine.Transport is not null) configuration.Mcp.Transport = commandLine.Transport; ConfigurationLoader.Validate(configuration, Path.GetDirectoryName(configPath)!); - string language = commandLine.Language ?? configuration.Application.Language ?? "en"; + string language = configuration.Application.Language ?? "en"; string baseDirectory = Path.GetDirectoryName(configPath)!; string localeDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Localization.Directory); LocaleCatalog locale = LocaleCatalog.Load(localeDirectory, language, configuration.Localization.Fallback); _ = FixedPrompt.Load(); string logDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Logging.Directory); - if (commandLine.Command is not (AppCommand.Serve or AppCommand.Check)) - return RunManagementCommand(commandLine, configuration, configPath, logDirectory, locale); + if (commandLine.Command is AppCommand.Apply or AppCommand.Show) + return RunUnifiedManagementCommand(commandLine, configuration, configPath, logDirectory, locale); string stateDirectory = Path.Combine(baseDirectory, "state"); string journalDirectory = Path.Combine(stateDirectory, "journals"); string backupDirectory = Path.Combine(stateDirectory, "backups"); - EffectiveSandboxPolicy effectiveSandboxPolicy = new SandboxEnvironmentResolver(configPath, configuration) + EffectiveSandboxPolicy effectiveSandboxPolicy = new AppliedSandboxConfiguration(configPath, configuration) .Compile(journalDirectory, backupDirectory); - if (commandLine.Command == AppCommand.Check) - { - WindowsPlatformCheckResult platform = WindowsPlatformCheck.Run(); - Console.Out.WriteLine($"administrator={platform.IsAdministrator.ToString().ToLowerInvariant()}"); - Console.Out.WriteLine($"bfe_engine_open={platform.BfeEngineOpen.ToString().ToLowerInvariant()}"); - Console.Out.WriteLine($"wfp_write_transaction={platform.WfpWriteTransaction.ToString().ToLowerInvariant()}"); - Console.Out.WriteLine($"wfp_filter_add_delete={platform.WfpFilterAddDelete.ToString().ToLowerInvariant()}"); - if (!platform.Success) - { - string detail = $"{locale.Get("check.wfp_admin_required")} error_code={platform.ErrorCode}; operation={platform.ErrorOperation}"; - string failure = locale.Format("check.failure", new Dictionary<string, object?> { ["message"] = detail }); - Console.Error.WriteLine(failure); - return 2; - } - Console.Out.WriteLine(locale.Get("check.success")); - return 0; - } UserOnlyDirectory.Ensure(logDirectory); using var log = new RollingFileLog(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles, configuration.Logging.Level); @@ -130,8 +111,6 @@ internal static class Program internal static string FormatStartupError(string[] args, Exception error) { string language = "en"; - for (int i = 0; i + 1 < args.Length; i++) - if (args[i] == "--lang") language = args[i + 1]; try { string directory = Path.Combine(AppContext.BaseDirectory, "config", "locales"); @@ -158,12 +137,7 @@ internal static class Program { string? key = error.Code switch { - "sandbox.resolution_unconfirmed" => "sandbox.error.resolution_unconfirmed", - "sandbox.resolution_changed" => "sandbox.error.resolution_changed", - "executable.approval_missing" => "sandbox.error.approval_missing", - "executable.resolution_changed" => "sandbox.error.executable_resolution_changed", - "executable.hash_changed" => "sandbox.error.executable_hash_changed", - "executable.shell_unsupported" => "sandbox.error.shell_unsupported", + "sandbox.configuration_not_applied" => "sandbox.error.configuration_not_applied", _ => null }; string localized = key is null ? error.Message : locale.Format(key, new Dictionary<string, object?> { ["target"] = error.Target ?? "-" }); @@ -171,6 +145,75 @@ internal static class Program return $"code={error.Code}; category={error.Category}; target={error.Target ?? "-"}; message={localized}; resolution={resolution}"; } + private static int RunUnifiedManagementCommand(CommandLine command, AgentConfiguration configuration, + string configPath, string logDirectory, LocaleCatalog locale) + { + var appliedConfiguration = new AppliedSandboxConfiguration(configPath, configuration); + AppliedConfigurationPreview preview = appliedConfiguration.Resolve(); + AppliedConfigurationDocument? applied; + try { applied = appliedConfiguration.LoadApplied(); } + catch (ConfigurationException error) when (command.Command == AppCommand.Apply) + { + applied = null; + Console.Out.WriteLine(locale.Format("apply.store_unreadable", new Dictionary<string, object?> { ["message"] = error.Message })); + } + bool current = appliedConfiguration.IsApplied(preview, applied); + PrintAppliedState(preview, applied, current, locale); + if (command.Command == AppCommand.Show) return 0; + + Console.Out.WriteLine("\n" + locale.Get(current ? "apply.no_changes" : "apply.replace_snapshot")); + ConfirmMutation(command.Yes, locale.Get("apply.prompt")); + UserOnlyDirectory.Ensure(logDirectory); + AppliedConfigurationDocument document = appliedConfiguration.Apply(); + var audit = new JsonLinesSecurityAuditSink(Path.Combine(logDirectory, "audit.jsonl")); + audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "sandbox.configuration.apply", "applied", + new Dictionary<string, string> + { + ["config_fingerprint"] = document.ConfigFingerprint, + ["execution_entries"] = document.Execution.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["non_interactive"] = command.Yes.ToString() + })); + Console.Out.WriteLine("\n" + locale.Get("apply.success")); + return 0; + } + + private static void PrintAppliedState(AppliedConfigurationPreview preview, AppliedConfigurationDocument? applied, + bool current, LocaleCatalog locale) + { + Console.Out.WriteLine(locale.Format("show.configuration", new Dictionary<string, object?> { ["path"] = preview.ConfigPath })); + Console.Out.WriteLine(locale.Format("show.applied", new Dictionary<string, object?> { ["value"] = locale.Get(current ? "show.yes" : "show.no") })); + Console.Out.WriteLine(locale.Format("show.fingerprint", new Dictionary<string, object?> { ["value"] = locale.Get(applied is null ? "show.not_applied" : current ? "show.matched" : "show.changed") })); + Console.Out.WriteLine(locale.Get("show.platform")); + Console.Out.WriteLine("\n" + locale.Get("show.filesystem")); + Console.Out.WriteLine(locale.Get("show.filesystem_columns")); + foreach (ResolutionItem item in preview.Filesystem) + Console.Out.WriteLine($"{item.Kind}\t{item.LogicalPath}\t{item.ResolvedPath}\t{item.Access}\t{locale.Get("show.status." + FilesystemStatus(item, applied))}"); + foreach (ResolutionItem item in applied?.Filesystem.Except(preview.Filesystem) ?? []) + Console.Out.WriteLine($"{item.Kind}\t{item.LogicalPath}\t{item.ResolvedPath}\t{item.Access}\t{locale.Get("show.status.removed")}"); + Console.Out.WriteLine("\n" + locale.Get("show.execution")); + Console.Out.WriteLine(locale.Get("show.execution_columns")); + foreach (AppliedExecutionEntry item in preview.Execution) + Console.Out.WriteLine($"{item.Alias}\t{item.Classification}\t{item.ResolvedPath}\t{item.ResolvedRuntimeRoot ?? "-"}\t{item.Sha256}\t{locale.Get("show.status." + ExecutionStatus(item, applied))}"); + foreach (AppliedExecutionEntry item in applied?.Execution.Where(old => preview.Execution.All(now => !now.Alias.Equals(old.Alias, StringComparison.OrdinalIgnoreCase))) ?? []) + Console.Out.WriteLine($"{item.Alias}\t{item.Classification}\t{item.ResolvedPath}\t{item.ResolvedRuntimeRoot ?? "-"}\t{item.Sha256}\t{locale.Get("show.status.removed")}"); + Console.Out.WriteLine("\n" + locale.Get("show.network")); + string networkStatus = applied is not null && AppliedSandboxConfiguration.NetworkEquivalent(applied.Network, preview.Network) ? "active" : applied is null ? "added" : "changed"; + Console.Out.WriteLine($"access={preview.Network.Access} endpoints={string.Join(',', preview.Network.Endpoints.Select(item => $"{item.Host}:{string.Join('/', item.Ports)}"))} status={locale.Get("show.status." + networkStatus)}"); + Console.Out.WriteLine("\n" + locale.Get("show.resources")); + string resourceStatus = applied?.Resources == preview.Resources ? "active" : applied is null ? "added" : "changed"; + Console.Out.WriteLine($"profile={preview.Resources.Profile} memory_mb={preview.Resources.MemoryMb} process_limit={preview.Resources.ProcessLimit} timeout_seconds={preview.Resources.ExecutionTimeoutSeconds} status={locale.Get("show.status." + resourceStatus)}"); + } + + private static string FilesystemStatus(ResolutionItem item, AppliedConfigurationDocument? applied) => + applied is null ? "added" : applied.Filesystem.Contains(item) ? "active" : "changed"; + + private static string ExecutionStatus(AppliedExecutionEntry item, AppliedConfigurationDocument? applied) + { + if (applied is null) return "added"; + AppliedExecutionEntry? previous = applied.Execution.FirstOrDefault(old => old.Alias.Equals(item.Alias, StringComparison.OrdinalIgnoreCase)); + return previous is null ? "added" : previous == item ? "active" : "changed"; + } + private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, EffectiveSandboxPolicy effectiveSandboxPolicy, string logDirectory, SessionStore? sessionStore = null) { Directory.CreateDirectory(effectiveSandboxPolicy.JournalDirectory); @@ -197,109 +240,6 @@ internal static class Program MaxTasks: configuration.Planner.MaxTasks, Model: configuration.Ollama.Model); - private static int RunManagementCommand(CommandLine command, AgentConfiguration configuration, string configPath, string logDirectory, LocaleCatalog locale) - { - var resolver = new SandboxEnvironmentResolver(configPath, configuration); - var approvals = new ExecutableApprovalStore(); - var audit = new JsonLinesSecurityAuditSink(Path.Combine(logDirectory, "audit.jsonl")); - switch (command.Command) - { - case AppCommand.ExecutableResolve: - { - IReadOnlyList<string> candidates = ExecutableResolver.ResolveCandidates(command.Alias!, ResolveCliPath(command.ExecutablePath, configPath)); - foreach (string candidate in candidates) Console.Out.WriteLine(candidate); - if (candidates.Count > 1) Console.Out.WriteLine(locale.Get("management.ambiguous_candidates")); - return 0; - } - case AppCommand.ExecutableApprove: - { - UserOnlyDirectory.Ensure(logDirectory); - EnsureConfiguredAlias(configuration, command.Alias!, out bool shell); - if (command.RuntimeRoot is null && !command.NoRuntime) - throw new CommandLineException("Specify either --runtime-root <path> or --no-runtime when approving an Executable Alias."); - IReadOnlyList<string> candidates = ExecutableResolver.ResolveCandidates(command.Alias!, ResolveCliPath(command.ExecutablePath, configPath)); - if (candidates.Count != 1) - throw new ConfigurationException($"Executable Alias '{command.Alias}' is ambiguous. Re-run with --path and one of:{Environment.NewLine}{string.Join(Environment.NewLine, candidates)}"); - string resolved = candidates[0]; - if (shell && !ExecutableResolver.IsSupportedShell(resolved)) - throw new ConfigurationException($"Shell Alias '{command.Alias}' must resolve to cmd.exe, powershell.exe, or pwsh.exe.", - code: "executable.shell_unsupported", category: "sandbox", target: resolved, - resolution: "Select a supported Shell executable and approve it explicitly."); - string? runtimeRoot = command.RuntimeRoot is null ? null : ResolvedPath.Resolve(ResolveCliPath(command.RuntimeRoot, configPath)!); - if (runtimeRoot is not null && !Directory.Exists(runtimeRoot)) throw new ConfigurationException($"Runtime Root is not a directory: '{runtimeRoot}'."); - string sha256 = EntryExecutablePolicy.ComputeSha256(resolved); - Console.Out.WriteLine(locale.Format("management.approval_diff", new Dictionary<string, object?> - { - ["alias"] = command.Alias, ["classification"] = shell ? "shell" : "executable", - ["path"] = resolved, ["sha256"] = sha256, ["runtime_root"] = runtimeRoot ?? "(none)" - })); - ConfirmMutation(command.Yes, locale.Get("management.approve_prompt")); - approvals.Approve(new(command.Alias!, shell ? "shell" : "executable", command.ExecutablePath ?? resolved, - resolved, sha256, runtimeRoot, DateTimeOffset.UtcNow)); - audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "executable.approval.add", "approved", - new Dictionary<string, string> { ["alias"] = command.Alias!, ["resolved_path"] = resolved, ["non_interactive"] = command.Yes.ToString() })); - return 0; - } - case AppCommand.ExecutableList: - foreach (ExecutableApprovalRecord item in approvals.List()) - Console.Out.WriteLine($"{item.Alias}\t{item.Classification}\t{item.ResolvedPath}\t{item.Sha256}\t{item.RuntimeRoot ?? "-"}"); - return 0; - case AppCommand.ExecutableRevoke: - { - UserOnlyDirectory.Ensure(logDirectory); - ExecutableApprovalRecord record = approvals.Find(command.Alias!) ?? throw new ConfigurationException($"Executable Alias '{command.Alias}' is not approved."); - Console.Out.WriteLine(locale.Format("management.revoke_diff", new Dictionary<string, object?> { ["alias"] = record.Alias, ["path"] = record.ResolvedPath })); - ConfirmMutation(command.Yes, locale.Get("management.revoke_prompt")); - _ = approvals.Revoke(record.Alias); - audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "executable.approval.remove", "revoked", - new Dictionary<string, string> { ["alias"] = record.Alias, ["resolved_path"] = record.ResolvedPath, ["non_interactive"] = command.Yes.ToString() })); - return 0; - } - case AppCommand.SandboxResolve: - PrintResolution(resolver.Resolve(), "text", locale); - return 0; - case AppCommand.SandboxConfirm: - { - UserOnlyDirectory.Ensure(logDirectory); - SandboxResolutionPreview preview = resolver.Resolve(); - PrintResolution(preview, "text", locale); - ConfirmMutation(command.Yes, locale.Get("management.sandbox_confirm_prompt")); - SandboxResolutionDocument document = resolver.Confirm(); - audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "sandbox.resolution.confirm", "confirmed", - new Dictionary<string, string> { ["config_fingerprint"] = document.ConfigFingerprint, ["non_interactive"] = command.Yes.ToString() })); - return 0; - } - case AppCommand.SandboxShow: - { - SandboxResolutionPreview preview = resolver.Resolve(); - SandboxResolutionDocument? confirmed = new SandboxResolutionStore(configPath).Load(); - bool isConfirmed = confirmed is not null && confirmed.ConfigFingerprint == preview.Fingerprint; - EffectiveSandboxPolicy? policy = isConfirmed - ? resolver.Compile(Path.Combine(Path.GetDirectoryName(configPath)!, "state", "journals"), - Path.Combine(Path.GetDirectoryName(configPath)!, "state", "backups")) - : null; - object? effective = policy is null ? null : EffectivePolicyView(policy); - if (command.Format == "json") Console.Out.WriteLine(JsonSerializer.Serialize(new { current = preview, confirmed, effective_policy = effective }, new JsonSerializerOptions { WriteIndented = true })); - else - { - PrintResolution(preview, "text", locale); - Console.Out.WriteLine(locale.Format("management.confirmed", new Dictionary<string, object?> - { ["value"] = locale.Get(isConfirmed ? "management.yes" : "management.no") })); - if (effective is not null) Console.Out.WriteLine(JsonSerializer.Serialize(effective, new JsonSerializerOptions { WriteIndented = true })); - } - return 0; - } - default: throw new CommandLineException("Unsupported management command."); - } - } - - private static void EnsureConfiguredAlias(AgentConfiguration configuration, string alias, out bool shell) - { - bool executable = configuration.Sandbox.Execution.Executables.Contains(alias, StringComparer.OrdinalIgnoreCase); - shell = configuration.Sandbox.Execution.Shells.Contains(alias, StringComparer.OrdinalIgnoreCase); - if (!executable && !shell) throw new ConfigurationException($"Executable Alias '{alias}' is not declared in sandbox.execution.executables or sandbox.execution.shells."); - } - private static void ConfirmMutation(bool yes, string prompt) { if (yes) return; @@ -308,43 +248,4 @@ internal static class Program if (!string.Equals(Console.ReadLine(), "y", StringComparison.OrdinalIgnoreCase)) throw new CommandLineException("Operation cancelled."); } - private static void PrintResolution(SandboxResolutionPreview preview, string format, LocaleCatalog locale) - { - if (format == "json") { Console.Out.WriteLine(JsonSerializer.Serialize(preview, new JsonSerializerOptions { WriteIndented = true })); return; } - Console.Out.WriteLine(locale.Format("management.resolution_header", new Dictionary<string, object?> - { ["config"] = preview.ConfigPath, ["fingerprint"] = preview.Fingerprint })); - foreach (ResolutionItem item in preview.Items) - Console.Out.WriteLine($"{item.Kind}\t{item.Access}\t{item.LogicalPath} -> {item.ResolvedPath}"); - } - - private static string? ResolveCliPath(string? path, string configPath) => path is null ? null : - (Path.IsPathFullyQualified(path) ? path : Path.Combine(Path.GetDirectoryName(configPath)!, path)); - - private static object EffectivePolicyView(EffectiveSandboxPolicy policy) => new - { - filesystem_roots = policy.Files.Select(item => new { resolved_path = item.Path, capabilities = item.Capabilities.ToString(), origin = item.Origin }), - approved_entry_executables = policy.EntryExecutables.Select(item => new - { - alias = item.Alias, - resolved_path = item.ResolvedPath, - sha256 = item.Sha256, - classification = item.IsShell ? "approved_shell_executable" : "approved_entry_executable", - runtime_root = item.RuntimeRoot - }), - runtime_roots = policy.RuntimeRoots, - artifact_roots = policy.ArtifactRoots, - network = new - { - access = policy.NetworkAccess, - endpoints = policy.NetworkAllowances.Select(item => new { host = item.Host, ports = item.Ports }) - }, - resources = new - { - profile = policy.ResourceProfile, - process_limit = policy.JobLimits.MaxProcesses, - memory_bytes = policy.JobLimits.MaxJobMemoryBytes, - execution_timeout_seconds = policy.ProcessTimeoutSeconds - }, - baseline_capabilities = policy.BaselineCapabilities ?? ["registryRead", "lpacInstrumentation"] - }; } 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 |
