summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Agent.App/Cli/CommandLine.cs110
-rw-r--r--src/Agent.App/Configuration/AgentConfiguration.cs11
-rw-r--r--src/Agent.App/Configuration/ConfigurationLoader.cs13
-rw-r--r--src/Agent.App/Program.cs247
-rw-r--r--src/Agent.App/Sandbox/AppliedSandboxConfiguration.cs267
-rw-r--r--src/Agent.App/Sandbox/SandboxServices.cs333
-rw-r--r--src/Agent.Windows/Security/ExecutablePolicy.cs4
7 files changed, 391 insertions, 594 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
diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs
index 02306fd..f42569a 100644
--- a/src/Agent.Windows/Security/ExecutablePolicy.cs
+++ b/src/Agent.Windows/Security/ExecutablePolicy.cs
@@ -33,7 +33,7 @@ public sealed class EntryExecutablePolicy(
if (_artifactRoots.Any(root => IsAtOrBelow(resolved, root)))
return new(resolved, ComputeSha256(resolved), EntryExecutableClassification.WorkspaceArtifact);
throw new SandboxPolicyException("process.entry_unknown", $"External Unknown Executable cannot be started as an Entry Process: '{resolved}'.",
- "process", resolved, "execute", "Configure an Executable Alias and approve it, or place the Workspace Artifact in a development Workspace.");
+ "process", resolved, "execute", "Add an execution.entries item to YAML and run agent-mcp apply, or place the Workspace Artifact in a development Workspace.");
}
public string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments, string? baseDirectory = null)
@@ -77,7 +77,7 @@ public sealed class EntryExecutablePolicy(
string actualHash = ComputeSha256(resolved);
if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(approval.Sha256), Convert.FromHexString(actualHash)))
throw new SandboxPolicyException("process.hash_denied", $"Approved Entry Executable SHA-256 changed: '{resolved}'.",
- "process", resolved, "execute", $"Re-run executable approve for Alias '{approval.Alias}'.");
+ "process", resolved, "execute", $"Review Alias '{approval.Alias}' in YAML and run agent-mcp apply.");
return new(resolved, actualHash, approval.IsShell ? EntryExecutableClassification.ApprovedShellExecutable : EntryExecutableClassification.ApprovedEntryExecutable, approval.Alias);
}