diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Agent.App/Agent.App.csproj | 1 | ||||
| -rw-r--r-- | src/Agent.App/Cli/CommandLine.cs | 102 | ||||
| -rw-r--r-- | src/Agent.App/Configuration/AgentConfiguration.cs | 31 | ||||
| -rw-r--r-- | src/Agent.App/Configuration/ConfigurationException.cs | 11 | ||||
| -rw-r--r-- | src/Agent.App/Configuration/ConfigurationLoader.cs | 86 | ||||
| -rw-r--r-- | src/Agent.App/Program.cs | 225 | ||||
| -rw-r--r-- | src/Agent.App/Sandbox/SandboxServices.cs | 361 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/AgentRuntime.cs | 12 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/PlannerService.cs | 38 | ||||
| -rw-r--r-- | src/Agent.Windows/NativeMethods.txt | 9 | ||||
| -rw-r--r-- | src/Agent.Windows/Network/TaskNetworkPolicy.cs | 28 | ||||
| -rw-r--r-- | src/Agent.Windows/Recovery/TransactionalFileEditor.cs | 12 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/ExecutablePolicy.cs | 87 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/PathPolicy.cs | 37 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/RestrictedProcessLauncher.cs | 208 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/TaskAclManager.cs | 74 | ||||
| -rw-r--r-- | src/Agent.Windows/Tools/WindowsToolBackend.cs | 60 |
17 files changed, 1151 insertions, 231 deletions
diff --git a/src/Agent.App/Agent.App.csproj b/src/Agent.App/Agent.App.csproj index 17b7bc9..e3447c5 100644 --- a/src/Agent.App/Agent.App.csproj +++ b/src/Agent.App/Agent.App.csproj @@ -12,6 +12,7 @@ </ItemGroup> <ItemGroup> <InternalsVisibleTo Include="Agent.Core.Tests" /> + <InternalsVisibleTo Include="Agent.Windows.Tests" /> </ItemGroup> <ItemGroup> <PackageReference Include="Microsoft.Extensions.Hosting" /> diff --git a/src/Agent.App/Cli/CommandLine.cs b/src/Agent.App/Cli/CommandLine.cs index 3fdac9f..3b3fde1 100644 --- a/src/Agent.App/Cli/CommandLine.cs +++ b/src/Agent.App/Cli/CommandLine.cs @@ -1,38 +1,104 @@ namespace Agent.App.Cli; -public enum AppCommand { Serve, Check } +public enum AppCommand +{ + Serve, + Check, + ExecutableResolve, + ExecutableApprove, + ExecutableList, + ExecutableRevoke, + SandboxResolve, + SandboxConfirm, + SandboxShow +} -public sealed record CommandLine(AppCommand Command, string? ConfigPath, string? Language, string? Transport) +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") { public static CommandLine Parse(string[] args) { bool implicitServe = args.Length == 0 || args[0].StartsWith("--", StringComparison.Ordinal); - AppCommand command = implicitServe ? AppCommand.Serve : args[0] switch + 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") { - "serve" => AppCommand.Serve, - "check" => AppCommand.Check, - _ => throw new CommandLineException("Expected 'serve' or 'check'.") - }; + 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'."); string? config = null; string? language = null; string? transport = null; - for (int i = implicitServe ? 0 : 1; i < args.Length; i++) + string? executablePath = null; + string? runtimeRoot = null; + bool noRuntime = false; + bool yes = false; + string format = "text"; + while (index < args.Length) { - string option = args[i]; - if (i + 1 >= args.Length) throw new CommandLineException($"Missing value for '{option}'."); - string value = args[++i]; + string option = args[index++]; switch (option) { - case "--config" when config is null: config = value; break; - case "--lang" when language is null: language = value; break; - case "--transport" when command == AppCommand.Serve && transport is null && value is "stdio" or "http": transport = value; break; - case "--transport" when command == AppCommand.Check: throw new CommandLineException("--transport is only valid for serve."); - case "--transport": throw new CommandLineException("Transport must be stdio or http."); - default: throw new CommandLineException($"Unknown or duplicate option '{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."); } } - return new CommandLine(command, config, language, transport); + 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++]; + } } } diff --git a/src/Agent.App/Configuration/AgentConfiguration.cs b/src/Agent.App/Configuration/AgentConfiguration.cs index c5f720b..b2f94a7 100644 --- a/src/Agent.App/Configuration/AgentConfiguration.cs +++ b/src/Agent.App/Configuration/AgentConfiguration.cs @@ -64,13 +64,32 @@ public sealed class OllamaGenerationOptions } public sealed class PlannerOptions { public int MaxTasks { get; set; } = 32; } -public sealed class SandboxOptions { public FilesystemOptions Filesystem { get; set; } = new(); public ProcessOptions Process { get; set; } = new(); public NetworkOptions Network { get; set; } = new(); public ResourceOptions Resources { get; set; } = new(); } -public sealed class FilesystemOptions { public List<string> Read { get; set; } = []; public List<string> Write { get; set; } = []; public List<string> Execute { get; set; } = []; public List<string> Delete { get; set; } = []; } -public sealed class ProcessOptions { public bool AllowShell { get; set; } = true; public List<ExecutableApproval> Approvals { get; set; } = []; public int MaxProcesses { get; set; } = 16; } -public sealed class ExecutableApproval { public string Path { get; set; } = string.Empty; public string Sha256 { get; set; } = string.Empty; } -public sealed class NetworkOptions { public string Mode { get; set; } = "deny"; public List<NetworkAllowance> Allow { get; set; } = []; } +public sealed class SandboxOptions +{ + public string Profile { get; set; } = "readonly"; + public List<string> Workspaces { get; set; } = []; + public List<MountOptions> Mounts { get; set; } = []; + public ExecutionOptions Execution { get; set; } = new(); + public NetworkOptions Network { get; set; } = new(); + 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 NetworkOptions +{ + public string Access { get; set; } = "none"; + public List<LocalEndpointOptions> LocalEndpoints { get; set; } = []; + public List<NetworkAllowance> RestrictedEndpoints { get; set; } = []; +} +public sealed class LocalEndpointOptions { public int Port { get; set; } } public sealed class NetworkAllowance { public string Host { get; set; } = string.Empty; public List<int> Ports { get; set; } = []; } -public sealed class ResourceOptions { public int MemoryMb { get; set; } = 4096; public int ProcessTimeoutSeconds { get; set; } = 1800; } +public sealed class ResourceOptions +{ + public string Profile { get; set; } = "normal"; + public int? MemoryMb { get; set; } + public int? ProcessLimit { get; set; } + public int? ExecutionTimeoutSeconds { get; set; } +} public sealed class FileEditOptions { public int MemoryBackupLimitMb { get; set; } = 16; } public sealed class LimitOptions { public long WholeReadBytes { get; set; } = 1024 * 1024; public int SearchMaxResults { get; set; } = 1000; public int TailMaxLines { get; set; } = 10000; public double ContextSummaryRatio { get; set; } = 0.8; } public sealed class LocalizationOptions { public string Directory { get; set; } = "config/locales"; public string Fallback { get; set; } = "en"; } diff --git a/src/Agent.App/Configuration/ConfigurationException.cs b/src/Agent.App/Configuration/ConfigurationException.cs index 67f38a5..dd14ea9 100644 --- a/src/Agent.App/Configuration/ConfigurationException.cs +++ b/src/Agent.App/Configuration/ConfigurationException.cs @@ -1,4 +1,11 @@ namespace Agent.App.Configuration; -public sealed class ConfigurationException(string message, Exception? innerException = null) - : Exception(message, innerException); +public sealed class ConfigurationException(string message, Exception? innerException = null, + string code = "configuration.invalid", string category = "configuration", string? target = null, + string? resolution = null) : Exception(message, innerException) +{ + public string Code { get; } = code; + public string Category { get; } = category; + public string? Target { get; } = target; + public string? Resolution { get; } = resolution; +} diff --git a/src/Agent.App/Configuration/ConfigurationLoader.cs b/src/Agent.App/Configuration/ConfigurationLoader.cs index 26c56e1..6e15904 100644 --- a/src/Agent.App/Configuration/ConfigurationLoader.cs +++ b/src/Agent.App/Configuration/ConfigurationLoader.cs @@ -22,6 +22,7 @@ public static partial class ConfigurationLoader string localPath = Path.Combine(Path.GetDirectoryName(absolutePath)!, "agent.local.yaml"); if (!string.Equals(absolutePath, localPath, StringComparison.OrdinalIgnoreCase) && File.Exists(localPath)) yaml = MergeYaml(yaml, File.ReadAllText(localPath)); + RejectLegacySandbox(yaml); var deserializer = new DeserializerBuilder() .WithNamingConvention(UnderscoredNamingConvention.Instance) .Build(); @@ -61,26 +62,7 @@ public static partial class ConfigurationLoader if (value.Ollama.Options.NumCtx is <= 0) Throw("ollama.options.num_ctx must be positive."); if (value.Ollama.Options.Temperature is < 0 or > 2) Throw("ollama.options.temperature must be in 0..2."); if (value.Planner.MaxTasks is < 1 or > 32) Throw("planner.max_tasks must be in 1..32."); - if (value.Sandbox.Process.MaxProcesses <= 0 || value.Sandbox.Resources.MemoryMb <= 0 || value.Sandbox.Resources.ProcessTimeoutSeconds <= 0) Throw("sandbox resource limits must be positive."); - if (value.Sandbox.Network.Mode is not ("deny" or "allow")) Throw("sandbox.network.mode must be deny or allow."); - foreach (NetworkAllowance allowance in value.Sandbox.Network.Allow) - { - if (string.IsNullOrWhiteSpace(allowance.Host) || allowance.Ports.Count == 0 || allowance.Ports.Any(static p => p is < 1 or > 65535)) Throw("sandbox.network.allow contains an invalid host or port."); - } - foreach (ExecutableApproval approval in value.Sandbox.Process.Approvals) - { - if (!Path.IsPathFullyQualified(approval.Path) || !Sha256Regex().IsMatch(approval.Sha256)) Throw("process approval requires an absolute path and 64 hexadecimal SHA-256 characters."); - string approvalPath = Path.GetFullPath(approval.Path); - bool executable = value.Sandbox.Filesystem.Execute.Any(entry => ContainsPath(baseDirectory, entry, approvalPath)); - if (!executable) Throw($"sandbox.process.approvals path is not covered by sandbox.filesystem.execute: {approvalPath}. Add the path or an ancestor directory to sandbox.filesystem.execute."); - } - foreach (string executeEntry in value.Sandbox.Filesystem.Execute) - { - string executePath = Path.IsPathFullyQualified(executeEntry) ? Path.GetFullPath(executeEntry) : Resolve(baseDirectory, executeEntry); - if (File.Exists(executePath) && !value.Sandbox.Process.Approvals.Any(approval => - string.Equals(Path.GetFullPath(approval.Path), executePath, StringComparison.OrdinalIgnoreCase))) - Throw($"sandbox.filesystem.execute file has no sandbox.process.approvals entry: {executePath}. Add canonical path and SHA-256 under sandbox.process.approvals."); - } + ValidateSandbox(value.Sandbox); if (value.FileEdit.MemoryBackupLimitMb <= 0 || value.Limits.WholeReadBytes <= 0 || value.Limits.SearchMaxResults <= 0 || value.Limits.TailMaxLines <= 0 || value.Limits.ContextSummaryRatio is <= 0 or >= 1) Throw("file/context limits are invalid."); if (value.Logging.MaxFileBytes <= 0 || value.Logging.RetainedFiles < 1) Throw("logging limits are invalid."); if (value.Logging.Level is not ("information" or "warning" or "error" or "fatal")) Throw("logging.level must be information, warning, error, or fatal."); @@ -102,12 +84,62 @@ public static partial class ConfigurationLoader public static string Resolve(string baseDirectory, string path) => Path.GetFullPath(path, baseDirectory); - private static bool ContainsPath(string baseDirectory, string configuredPath, string target) + private static void ValidateSandbox(SandboxOptions sandbox) + { + if (sandbox.Profile is not ("readonly" or "development")) Throw("sandbox.profile must be readonly or development."); + if (sandbox.Workspaces.Any(string.IsNullOrWhiteSpace)) Throw("sandbox.workspaces contains an empty Logical Path."); + foreach (MountOptions mount in sandbox.Mounts) + { + if (string.IsNullOrWhiteSpace(mount.Path) || mount.Access is not ("read_only" or "read_write" or "read_write_delete")) + Throw("sandbox.mounts requires a Logical Path and access read_only, read_write, or read_write_delete."); + 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(); + 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."); + + if (sandbox.Network.Access is not ("none" or "local" or "restricted")) + Throw("sandbox.network.access must be none, local, or restricted."); + if (sandbox.Network.LocalEndpoints.Any(static endpoint => endpoint.Port is < 1 or > 65535)) + Throw("sandbox.network.local_endpoints contains an invalid port."); + foreach (NetworkAllowance endpoint in sandbox.Network.RestrictedEndpoints) + if (string.IsNullOrWhiteSpace(endpoint.Host) || endpoint.Ports.Count == 0 || endpoint.Ports.Any(static port => port is < 1 or > 65535)) + Throw("sandbox.network.restricted_endpoints contains an invalid host or port."); + if (sandbox.Network.Access == "none" && (sandbox.Network.LocalEndpoints.Count != 0 || sandbox.Network.RestrictedEndpoints.Count != 0)) + Throw("sandbox.network.access none cannot contain endpoints."); + if (sandbox.Network.Access == "local" && (sandbox.Network.LocalEndpoints.Count == 0 || sandbox.Network.RestrictedEndpoints.Count != 0)) + Throw("sandbox.network.access local requires local_endpoints and forbids restricted_endpoints."); + if (sandbox.Network.Access == "restricted" && (sandbox.Network.RestrictedEndpoints.Count == 0 || sandbox.Network.LocalEndpoints.Count != 0)) + Throw("sandbox.network.access restricted requires restricted_endpoints and forbids local_endpoints."); + + if (sandbox.Resources.Profile is not ("constrained" or "normal" or "extended")) + Throw("sandbox.resources.profile must be constrained, normal, or extended."); + if (sandbox.Resources.MemoryMb is <= 0 || sandbox.Resources.ProcessLimit is <= 0 || sandbox.Resources.ExecutionTimeoutSeconds is <= 0) + Throw("sandbox resource overrides must be positive."); + } + + private static void RejectLegacySandbox(string yaml) { - string allowed = Path.IsPathFullyQualified(configuredPath) ? Path.GetFullPath(configuredPath) : Resolve(baseDirectory, configuredPath); - if (string.Equals(allowed, target, StringComparison.OrdinalIgnoreCase)) return true; - string prefix = allowed.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; - return target.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + 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'."); + } + + private static bool TryGet(YamlMappingNode map, string name, out YamlNode? value) + { + foreach (KeyValuePair<YamlNode, YamlNode> pair in map.Children) + { + if (pair.Key is YamlScalarNode key && key.Value == name) + { + value = pair.Value; + return true; + } + } + value = null; + return false; } private static string MergeYaml(string baseYaml, string localYaml) @@ -154,6 +186,6 @@ public static partial class ConfigurationLoader } private static void Throw(string message) => throw new ConfigurationException(message); - [GeneratedRegex("^[0-9a-fA-F]{64}$", RegexOptions.CultureInvariant)] - private static partial Regex Sha256Regex(); + [GeneratedRegex("^[a-z0-9][a-z0-9._-]{0,63}$", RegexOptions.CultureInvariant)] + private static partial Regex AliasRegex(); } diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs index 3d2d2db..b58c914 100644 --- a/src/Agent.App/Program.cs +++ b/src/Agent.App/Program.cs @@ -1,6 +1,7 @@ using Agent.App.Cli; using Agent.App.Configuration; using Agent.App.Logging; +using Agent.App.Sandbox; using Agent.Core.Localization; using Agent.Core.Prompts; using Agent.Core.Sessions; @@ -11,6 +12,7 @@ using Agent.Core.Tools; using Agent.Windows.Recovery; using Agent.Windows.Security; using Agent.Windows.Tools; +using System.Text.Json; namespace Agent.App; @@ -35,8 +37,13 @@ internal static class Program _ = FixedPrompt.Load(); string logDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Logging.Directory); - UserOnlyDirectory.Ensure(logDirectory); - using var log = new RollingFileLog(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles, configuration.Logging.Level); + if (commandLine.Command is not (AppCommand.Serve or AppCommand.Check)) + return RunManagementCommand(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) + .Compile(journalDirectory, backupDirectory); if (commandLine.Command == AppCommand.Check) { WindowsPlatformCheckResult platform = WindowsPlatformCheck.Run(); @@ -48,15 +55,15 @@ internal static class Program { 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 }); - log.Error(failure); Console.Error.WriteLine(failure); return 2; } - log.Information(locale.Get("check.success")); 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); log.Information(locale.Get("app.starting")); WindowsPlatformCheckResult servePlatform = WindowsPlatformCheck.Run(); if (!servePlatform.Success) @@ -75,7 +82,7 @@ internal static class Program } var sessionStore = new SessionStore(); - InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, baseDirectory, logDirectory, sessionStore); + InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, effectiveSandboxPolicy, logDirectory, sessionStore); using var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles); string taskStateDirectory = Path.Combine(baseDirectory, "state", "mcp-tasks"); UserOnlyDirectory.Ensure(taskStateDirectory); @@ -136,7 +143,10 @@ internal static class Program LocaleException => "locale.invalid", _ => "app.io_error" }; - return locale.Format(key, new Dictionary<string, object?> { ["message"] = error.Message }); + string message = error is ConfigurationException configuration + ? FormatConfigurationError(locale, configuration) + : error.Message; + return locale.Format(key, new Dictionary<string, object?> { ["message"] = message }); } catch { @@ -144,39 +154,33 @@ internal static class Program } } - private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, string baseDirectory, string logDirectory, SessionStore? sessionStore = null) + private static string FormatConfigurationError(LocaleCatalog locale, ConfigurationException error) { - string stateDirectory = Path.Combine(baseDirectory, "state"); - string journalDirectory = Path.Combine(stateDirectory, "journals"); - string backupDirectory = Path.Combine(stateDirectory, "backups"); - Directory.CreateDirectory(journalDirectory); - Directory.CreateDirectory(backupDirectory); + 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", + _ => null + }; + string localized = key is null ? error.Message : locale.Format(key, new Dictionary<string, object?> { ["target"] = error.Target ?? "-" }); + string resolution = key is null || error.Resolution is null ? error.Resolution ?? "-" : locale.Get(key + ".resolution"); + return $"code={error.Code}; category={error.Category}; target={error.Target ?? "-"}; message={localized}; resolution={resolution}"; + } + + private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, EffectiveSandboxPolicy effectiveSandboxPolicy, string logDirectory, SessionStore? sessionStore = null) + { + Directory.CreateDirectory(effectiveSandboxPolicy.JournalDirectory); + Directory.CreateDirectory(effectiveSandboxPolicy.BackupDirectory); var audit = new JsonLinesSecurityAuditSink(Path.Combine(logDirectory, "audit.jsonl")); - RecoveryResult recovery = new RecoveryManager(journalDirectory, audit).RecoverAll(); + RecoveryResult recovery = new RecoveryManager(effectiveSandboxPolicy.JournalDirectory, audit).RecoverAll(); if (!recovery.IsClean) throw new InvalidOperationException("Recovery did not reach a clean state: " + string.Join(" | ", recovery.Errors)); - var fileEntries = new List<FilePolicyEntry>(); - Add(configuration.Sandbox.Filesystem.Read, FileCapability.Read); - Add(configuration.Sandbox.Filesystem.Write, FileCapability.Write); - Add(configuration.Sandbox.Filesystem.Execute, FileCapability.Execute); - Add(configuration.Sandbox.Filesystem.Delete, FileCapability.Delete); - var settings = new WindowsSandboxSettings( - fileEntries, - configuration.Sandbox.Process.Approvals.Select(approval => new Agent.Windows.Security.ExecutableApproval(approval.Path, approval.Sha256)).ToArray(), - configuration.Sandbox.Process.AllowShell, - new SandboxJobLimits((uint)configuration.Sandbox.Process.MaxProcesses, checked((ulong)configuration.Sandbox.Resources.MemoryMb * 1024 * 1024)), - configuration.Sandbox.Network.Mode, - configuration.Sandbox.Network.Allow.Select(item => (item.Host, (IReadOnlyList<int>)item.Ports)).ToArray(), - journalDirectory, backupDirectory, checked((long)configuration.FileEdit.MemoryBackupLimitMb * 1024 * 1024), - configuration.Limits.WholeReadBytes, configuration.Limits.SearchMaxResults, configuration.Limits.TailMaxLines, - configuration.Sandbox.Resources.ProcessTimeoutSeconds); - var backend = new WindowsToolBackend(settings, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks, sessionStore)); + var backend = new WindowsToolBackend(effectiveSandboxPolicy, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks, sessionStore)); var allowed = InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal); return new InternalToolDispatcher(new DenyByDefaultPolicy(allowed), backend); - - void Add(IEnumerable<string> paths, FileCapability capability) - { - foreach (string path in paths) fileEntries.Add(new FilePolicyEntry(Path.IsPathFullyQualified(path) ? path : ConfigurationLoader.Resolve(baseDirectory, path), capability)); - } } internal static AgentRuntimeSettings CreateRuntimeSettings(AgentConfiguration configuration, string userPrompt, string language, @@ -184,7 +188,7 @@ internal static class Program userPrompt, language, skills, - "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", + "Windows LPAC, ACL, Job Object process-tree, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported"), @@ -192,4 +196,155 @@ internal static class Program configuration.Limits.ContextSummaryRatio, 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; + if (Console.IsInputRedirected) throw new CommandLineException("Interactive confirmation is unavailable; review the displayed diff and re-run with --yes."); + Console.Out.Write($"{prompt} [y/N] "); + 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/SandboxServices.cs b/src/Agent.App/Sandbox/SandboxServices.cs new file mode 100644 index 0000000..93211b8 --- /dev/null +++ b/src/Agent.App/Sandbox/SandboxServices.cs @@ -0,0 +1,361 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Agent.App.Configuration; +using Agent.Windows.Security; +using Agent.Windows.Tools; + +namespace Agent.App.Sandbox; + +public sealed record ExecutableApprovalRecord( + string Alias, + string Classification, + string LogicalPath, + string ResolvedPath, + string Sha256, + string? RuntimeRoot, + DateTimeOffset ApprovedAt); + +public sealed record ExecutableApprovalDocument(int SchemaVersion, IReadOnlyList<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; + } +} + +internal static class AtomicJson +{ + public static void Write<T>(string path, T value, JsonSerializerOptions options) + { + string temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + JsonSerializer.Serialize(stream, value, options); + stream.Flush(flushToDisk: true); + } + File.Move(temporary, path, overwrite: true); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } +} diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs index 5ca9d1c..676fe0d 100644 --- a/src/Agent.Core/Runtime/AgentRuntime.cs +++ b/src/Agent.Core/Runtime/AgentRuntime.cs @@ -303,7 +303,8 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag throw new PolicyDeniedException(toolResult.FailureFingerprint ?? $"policy:{call.Function.Name}:{toolResult.Output}", toolResult.Output, toolResult.ErrorCode ?? "policy_denied", toolResult.ErrorCategory, toolResult.TargetPath, toolResult.RequiredPermission, toolResult.Resolution); - failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint; + if (!toolResult.Success && !isMetadataTool) + failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint; messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name)); } bool verified = plannerTask.RequiredTools.All(successfulRequiredTools.Contains) && (!requiresExternalEffect || externalEffectEvidence); @@ -527,12 +528,19 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag private string LocalizePolicyDenial(PolicyDeniedException error) { if (locale is null) return error.Message; + string resolutionKey = error.ErrorCategory switch + { + "filesystem" => "runtime.policy_resolution.filesystem", + "process" => "runtime.policy_resolution.process", + "network" => "runtime.policy_resolution.network", + _ => "runtime.policy_resolution.default" + }; return locale.Format("runtime.policy_denied", new Dictionary<string, object?> { ["code"] = error.ErrorCode, ["path"] = error.TargetPath ?? "-", ["permission"] = error.RequiredPermission ?? "-", - ["resolution"] = error.Resolution ?? "-" + ["resolution"] = locale.Get(resolutionKey) }); } diff --git a/src/Agent.Core/Runtime/PlannerService.cs b/src/Agent.Core/Runtime/PlannerService.cs index 3591259..756cc41 100644 --- a/src/Agent.Core/Runtime/PlannerService.cs +++ b/src/Agent.Core/Runtime/PlannerService.cs @@ -31,12 +31,13 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se var messages = new List<OllamaMessage> { - new("system", FixedPrompt.Load()), - new("system", _settings.UserSystemPrompt), + // Planning has a dedicated read-only contract. Execution prompts contain instructions + // to inspect files and call tools, so mixing them here creates contradictory system + // instructions and causes models to execute narratively instead of returning a graph. + new("system", BuildPlanningInstruction()), new("system", $"Respond in locale: {_settings.Locale}."), new("system", ComposeSkillCatalog(_settings.Skills)), new("system", _settings.SandboxCapabilitySummary), - new("system", BuildPlanningInstruction()), new("user", request, images) }; @@ -58,11 +59,9 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se var messages = new List<OllamaMessage> { - new("system", FixedPrompt.Load()), - new("system", _settings.UserSystemPrompt), + new("system", BuildReplanInstruction(existing, replanReason)), new("system", $"Respond in locale: {_settings.Locale}."), new("system", _settings.SandboxCapabilitySummary), - new("system", BuildReplanInstruction(existing, replanReason)), new("user", request) }; @@ -74,6 +73,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se private TaskGraph BuildTaskGraph(PlanResult plan, string originalRequest) { + ValidatePlanStructure(plan); var graph = new TaskGraph(_settings.MaxTasks); var idMap = new List<Guid>(); @@ -105,6 +105,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se private TaskGraph RebuildTaskGraph(TaskGraph existing, PlanResult plan, string originalRequest) { + ValidatePlanStructure(plan); PlannerTask[] completed = existing.Tasks.Where(static t => t.State == PlannerTaskState.Completed).ToArray(); var completedByDescription = completed.GroupBy(static t => t.Description, StringComparer.Ordinal) .ToDictionary(static group => group.Key, static group => group.First(), StringComparer.Ordinal); @@ -182,6 +183,29 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se return tools; } + private static void ValidatePlanStructure(PlanResult plan) + { + for (int index = 0; index < plan.Tasks.Count; index++) + { + PlannedTask task = plan.Tasks[index]; + if (!task.RequiredTools.Contains("file_edit", StringComparer.Ordinal) || + !task.RequiredTools.Contains("process_run", StringComparer.Ordinal)) continue; + + var pending = new Stack<int>(task.Dependencies); + var visited = new HashSet<int>(); + while (pending.TryPop(out int dependency)) + { + if (dependency < 0 || dependency >= index || !visited.Add(dependency)) continue; + PlannedTask ancestor = plan.Tasks[dependency]; + if (ancestor.RequiredTools.Contains("file_edit", StringComparer.Ordinal)) + throw new PlannerValidationException( + "A coding implementation-and-verification Task cannot depend on a separate file_edit Task. " + + "Combine the edits, optional corrections, and process verification in one Task requiring file_edit and process_run."); + foreach (int parent in ancestor.Dependencies) pending.Push(parent); + } + } + } + private static PlanResult? ParsePlan(string content) { try @@ -266,7 +290,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se { attemptMessages = messages.Concat([ new OllamaMessage("system", - "The previous response was not a valid TaskGraph JSON object. Do not call or execute any Tool while planning. " + + $"The previous response was not a valid TaskGraph: {ex.Message} Do not call or execute any Tool while planning. " + "Return only one JSON object that matches the supplied schema, beginning with {\"tasks\":[ and containing no Markdown or commentary.") ]).ToArray(); } diff --git a/src/Agent.Windows/NativeMethods.txt b/src/Agent.Windows/NativeMethods.txt index 471b47d..61efc53 100644 --- a/src/Agent.Windows/NativeMethods.txt +++ b/src/Agent.Windows/NativeMethods.txt @@ -4,18 +4,25 @@ CreateJobObject CreateProcess DeleteProcThreadAttributeList GetExitCodeProcess +GetTokenInformation InitializeProcThreadAttributeList +IsProcessInJob JOBOBJECT_EXTENDED_LIMIT_INFORMATION JOB_OBJECT_LIMIT -PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES +PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY PROC_THREAD_ATTRIBUTE_HANDLE_LIST +DeriveCapabilitySidsFromName +LocalFree +OpenProcessToken ResumeThread SetHandleInformation SetInformationJobObject TerminateJobObject UpdateProcThreadAttribute STARTUPINFOEXW +TOKEN_INFORMATION_CLASS +TOKEN_APPCONTAINER_INFORMATION WaitForSingleObject Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineOpen0 Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineClose0 diff --git a/src/Agent.Windows/Network/TaskNetworkPolicy.cs b/src/Agent.Windows/Network/TaskNetworkPolicy.cs index bc4d291..9f78b3d 100644 --- a/src/Agent.Windows/Network/TaskNetworkPolicy.cs +++ b/src/Agent.Windows/Network/TaskNetworkPolicy.cs @@ -11,24 +11,25 @@ public sealed class TaskNetworkPolicy : IDisposable private readonly ISecurityAuditSink _audit; private readonly Guid _entryId; private readonly SecurityIdentifier _appContainerSid; - private readonly Guid _loopbackEntryId; + private readonly Guid? _loopbackEntryId; private readonly bool _loopbackAdded; private readonly Guid? _sessionId; private bool _disposed; private TaskNetworkPolicy(WfpDynamicSession session, TaskResourceJournal journal, ISecurityAuditSink audit, Guid entryId, - SecurityIdentifier appContainerSid, Guid loopbackEntryId, bool loopbackAdded, Guid? sessionId) + SecurityIdentifier appContainerSid, Guid? loopbackEntryId, bool loopbackAdded, Guid? sessionId) { _session = session; _journal = journal; _audit = audit; _entryId = entryId; _appContainerSid = appContainerSid; _loopbackEntryId = loopbackEntryId; _loopbackAdded = loopbackAdded; _sessionId = sessionId; } - public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> allowances, ISecurityAuditSink audit, Guid? sessionId = null) + public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> allowances, + ISecurityAuditSink audit, Guid? sessionId = null, bool enableLoopback = true) { - bool originallyExempt = LoopbackExemption.Contains(appContainerSid); - Guid loopbackEntryId = journal.Prepare("network.loopback_exemption", appContainerSid.Value, - new Dictionary<string, string> { ["originally_present"] = originallyExempt.ToString() }); + bool originallyExempt = enableLoopback && LoopbackExemption.Contains(appContainerSid); + Guid? loopbackEntryId = enableLoopback ? journal.Prepare("network.loopback_exemption", appContainerSid.Value, + new Dictionary<string, string> { ["originally_present"] = originallyExempt.ToString() }) : null; bool loopbackAdded = false; Guid entryId = journal.Prepare("wfp.dynamic", appContainerSid.Value, new Dictionary<string, string> { @@ -37,15 +38,18 @@ public sealed class TaskNetworkPolicy : IDisposable WfpDynamicSession session = WfpDynamicSession.Open(); try { - loopbackAdded = LoopbackExemption.Ensure(appContainerSid); - journal.MarkApplied(loopbackEntryId, new Dictionary<string, string> { ["added"] = loopbackAdded.ToString() }); + if (enableLoopback) + { + loopbackAdded = LoopbackExemption.Ensure(appContainerSid); + journal.MarkApplied(loopbackEntryId!.Value, new Dictionary<string, string> { ["added"] = loopbackAdded.ToString() }); + } IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(appContainerSid, allowances); journal.MarkApplied(entryId, new Dictionary<string, string> { ["filter_ids"] = string.Join(',', ids) }); audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.rule.apply", "applied", new Dictionary<string, string> { ["ipv4_ipv6_default_block"] = "true", ["filter_count"] = ids.Count.ToString(), ["session"] = "dynamic" }, SessionId: sessionId)); - audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", loopbackAdded ? "added" : "preserved", + if (enableLoopback) audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", loopbackAdded ? "added" : "preserved", new Dictionary<string, string> { ["app_container_sid"] = appContainerSid.Value }, SessionId: sessionId)); return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded, sessionId); } @@ -54,7 +58,7 @@ public sealed class TaskNetworkPolicy : IDisposable session.Dispose(); journal.MarkCleaned(entryId); if (loopbackAdded) LoopbackExemption.Remove(appContainerSid); - journal.MarkCleaned(loopbackEntryId); + if (loopbackEntryId is not null) journal.MarkCleaned(loopbackEntryId.Value); throw; } } @@ -66,9 +70,9 @@ public sealed class TaskNetworkPolicy : IDisposable _session.Dispose(); _journal.MarkCleaned(_entryId); if (_loopbackAdded) LoopbackExemption.Remove(_appContainerSid); - _journal.MarkCleaned(_loopbackEntryId); + if (_loopbackEntryId is not null) _journal.MarkCleaned(_loopbackEntryId.Value); _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.rule.remove", "removed", new Dictionary<string, string> { ["residual_filters"] = "0" }, SessionId: _sessionId)); - _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed", + if (_loopbackEntryId is not null) _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed", new Dictionary<string, string> { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" }, SessionId: _sessionId)); _disposed = true; } diff --git a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs index 25c949d..ff4ac5a 100644 --- a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs +++ b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs @@ -11,7 +11,7 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string public FileEditTransaction Replace(string path, ReadOnlySpan<byte> content, string? expectedSha256 = null) { - string canonical = CanonicalPath.Resolve(path, requireExisting: false); + string canonical = ResolvedPath.Resolve(path, requireExisting: false); bool existed = File.Exists(canonical); byte[]? original = existed ? File.ReadAllBytes(canonical) : null; string originalHash = original is null ? string.Empty : Convert.ToHexString(SHA256.HashData(original)); @@ -45,7 +45,7 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string public FileDeleteTransaction Delete(string path, string? expectedSha256 = null) { - string canonical = CanonicalPath.Resolve(path); + string canonical = ResolvedPath.Resolve(path); byte[] original = File.ReadAllBytes(canonical); string originalHash = Convert.ToHexString(SHA256.HashData(original)); if (expectedSha256 is not null && !originalHash.Equals(NormalizeHash(expectedSha256), StringComparison.Ordinal)) @@ -71,7 +71,7 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string public DirectoryMkdirTransaction Mkdir(string path) { - string canonical = CanonicalPath.Resolve(path, requireExisting: false); + string canonical = ResolvedPath.Resolve(path, requireExisting: false); bool existed = Directory.Exists(canonical); if (existed) throw new InvalidOperationException($"Directory already exists: '{canonical}'."); if (File.Exists(canonical)) throw new InvalidOperationException($"A file exists at the target path: '{canonical}'."); @@ -83,7 +83,7 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string public DirectoryRmdirTransaction Rmdir(string path) { - string canonical = CanonicalPath.Resolve(path); + string canonical = ResolvedPath.Resolve(path); if (!Directory.Exists(canonical)) throw new DirectoryNotFoundException($"Directory does not exist: '{canonical}'."); if (Directory.EnumerateFileSystemEntries(canonical, "*", SearchOption.TopDirectoryOnly).Any()) throw new ToolValidationException("Directory is not empty. Remove contents first before removing the directory."); @@ -95,8 +95,8 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string public FileRenameTransaction Rename(string sourcePath, string destinationPath) { - string source = CanonicalPath.Resolve(sourcePath); - string destination = CanonicalPath.Resolve(destinationPath, requireExisting: false); + string source = ResolvedPath.Resolve(sourcePath); + string destination = ResolvedPath.Resolve(destinationPath, requireExisting: false); if (!File.Exists(source)) throw new FileNotFoundException($"Source file does not exist: '{source}'."); if (File.Exists(destination)) throw new ToolValidationException($"Destination already exists: '{destination}'. Rename overwrite is not allowed."); if (!source.StartsWith(System.IO.Path.GetPathRoot(source)!, StringComparison.OrdinalIgnoreCase) || diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs index 6c72724..02306fd 100644 --- a/src/Agent.Windows/Security/ExecutablePolicy.cs +++ b/src/Agent.Windows/Security/ExecutablePolicy.cs @@ -2,46 +2,91 @@ using System.Security.Cryptography; namespace Agent.Windows.Security; -public sealed record ExecutableApproval(string Path, string Sha256); -public sealed record VerifiedExecutable(string CanonicalPath, string Sha256); +public enum EntryExecutableClassification { ApprovedEntryExecutable, ApprovedShellExecutable, RuntimeComponent, WorkspaceArtifact } -public sealed class ExecutablePolicy(IEnumerable<ExecutableApproval> approvals, bool allowShell) +public sealed record ApprovedEntryExecutable(string Alias, string ResolvedPath, string Sha256, bool IsShell, string? RuntimeRoot); +public sealed record VerifiedEntryExecutable(string ResolvedPath, string Sha256, EntryExecutableClassification Classification, string? Alias = null); + +public sealed class EntryExecutablePolicy( + IEnumerable<ApprovedEntryExecutable> approvals, + IEnumerable<string> runtimeRoots, + IEnumerable<string> artifactRoots) { - private readonly IReadOnlyDictionary<string, string> _approvals = approvals.ToDictionary( - approval => CanonicalPath.Resolve(approval.Path), approval => NormalizeHash(approval.Sha256), StringComparer.OrdinalIgnoreCase); + private readonly IReadOnlyDictionary<string, ApprovedEntryExecutable> _aliases = approvals.ToDictionary( + approval => approval.Alias, Normalize, StringComparer.OrdinalIgnoreCase); + private readonly string[] _runtimeRoots = runtimeRoots.Select(path => ResolvedPath.Resolve(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + private readonly string[] _artifactRoots = artifactRoots.Select(path => ResolvedPath.Resolve(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - public VerifiedExecutable Verify(string executable) + public VerifiedEntryExecutable VerifyEntry(string executable, string? baseDirectory = null) { - string canonical = CanonicalPath.Resolve(executable); - if (!allowShell && IsShell(canonical)) throw new SandboxPolicyException("process.shell_denied", "Shell execution is disabled.", "process", canonical, "execute", "sandbox.process.allow_shell"); - if (!_approvals.TryGetValue(canonical, out string? approvedHash)) throw new SandboxPolicyException("process.path_denied", $"Executable path is not approved: '{canonical}'. Add its canonical path and SHA-256 to sandbox.process.approvals.", "process", canonical, "execute", "sandbox.process.approvals"); - using FileStream stream = new(canonical, FileMode.Open, FileAccess.Read, FileShare.Read); - string actualHash = Convert.ToHexString(SHA256.HashData(stream)); - if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(approvedHash), Convert.FromHexString(actualHash))) - throw new SandboxPolicyException("process.hash_denied", $"Executable SHA-256 does not match its approval: '{canonical}'. Recalculate SHA-256 and update sandbox.process.approvals.", "process", canonical, "execute", "sandbox.process.approvals"); - return new(canonical, actualHash); + if (_aliases.TryGetValue(executable, out ApprovedEntryExecutable? byAlias)) return VerifyApproved(byAlias); + string candidate = Path.IsPathFullyQualified(executable) ? executable : Path.Combine(baseDirectory ?? Environment.CurrentDirectory, executable); + string resolved; + try { resolved = ResolvedPath.Resolve(candidate); } + catch (FileNotFoundException) + { + throw new SandboxPolicyException("process.alias_or_path_unknown", $"Executable Alias or Logical Path does not resolve to an existing file: '{executable}'.", + "process", Path.GetFullPath(candidate), "execute", "Use a configured Executable Alias or an existing Runtime Component/development Workspace Artifact path."); + } + if (_runtimeRoots.Any(root => IsAtOrBelow(resolved, root))) + return new(resolved, ComputeSha256(resolved), EntryExecutableClassification.RuntimeComponent); + 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."); } - public static string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments) + public string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments, string? baseDirectory = null) { - if (!Path.GetFileName(executable).Equals("dotnet.exe", StringComparison.OrdinalIgnoreCase) || arguments.Count == 0) return null; + string executablePath = Path.IsPathFullyQualified(executable) ? executable : Path.Combine(baseDirectory ?? Environment.CurrentDirectory, executable); + string resolved = _aliases.TryGetValue(executable, out ApprovedEntryExecutable? approval) ? approval.ResolvedPath : ResolvedPath.Resolve(executablePath); + if (!Path.GetFileName(resolved).Equals("dotnet.exe", StringComparison.OrdinalIgnoreCase) || arguments.Count == 0) return null; if (arguments[0].Equals("exec", StringComparison.OrdinalIgnoreCase)) { if (arguments.Count < 2 || arguments[1].StartsWith("-", StringComparison.Ordinal)) throw new SandboxPolicyException("process.managed_entry_invalid", "dotnet exec requires an explicit managed entry assembly as its next argument."); - return arguments[1]; + return ResolveArgument(arguments[1], baseDirectory); } - return arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? arguments[0] : null; + return arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? ResolveArgument(arguments[0], baseDirectory) : null; + } + + public void VerifyManagedEntryPoint(string path) + { + string resolved = ResolvedPath.Resolve(path); + if (_runtimeRoots.Any(root => IsAtOrBelow(resolved, root)) || _artifactRoots.Any(root => IsAtOrBelow(resolved, root))) return; + throw new SandboxPolicyException("process.managed_entry_unknown", $"Managed Entry Artifact is outside every confirmed Runtime Root and development Workspace: '{resolved}'.", + "process", resolved, "execute", "Place the artifact in a development Workspace or confirm its Runtime Root."); } public static string ComputeSha256(string executable) { - using FileStream stream = new(CanonicalPath.Resolve(executable), FileMode.Open, FileAccess.Read, FileShare.Read); + using FileStream stream = new(ResolvedPath.Resolve(executable), FileMode.Open, FileAccess.Read, FileShare.Read); return Convert.ToHexString(SHA256.HashData(stream)); } - private static bool IsShell(string path) => Path.GetFileName(path) is string name && - (name.Equals("cmd.exe", StringComparison.OrdinalIgnoreCase) || name.Equals("powershell.exe", StringComparison.OrdinalIgnoreCase) || name.Equals("pwsh.exe", StringComparison.OrdinalIgnoreCase)); + private static ApprovedEntryExecutable Normalize(ApprovedEntryExecutable approval) => approval with + { + ResolvedPath = ResolvedPath.Resolve(approval.ResolvedPath), + Sha256 = NormalizeHash(approval.Sha256), + RuntimeRoot = approval.RuntimeRoot is null ? null : ResolvedPath.Resolve(approval.RuntimeRoot) + }; + + private static VerifiedEntryExecutable VerifyApproved(ApprovedEntryExecutable approval) + { + string resolved = ResolvedPath.Resolve(approval.ResolvedPath); + 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}'."); + return new(resolved, actualHash, approval.IsShell ? EntryExecutableClassification.ApprovedShellExecutable : EntryExecutableClassification.ApprovedEntryExecutable, approval.Alias); + } + + private static bool IsAtOrBelow(string candidate, string root) => + candidate.Equals(root, StringComparison.OrdinalIgnoreCase) || + candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + private static string ResolveArgument(string path, string? baseDirectory) => Path.IsPathFullyQualified(path) + ? path : Path.GetFullPath(path, baseDirectory ?? Environment.CurrentDirectory); + private static string NormalizeHash(string hash) { string normalized = hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant(); diff --git a/src/Agent.Windows/Security/PathPolicy.cs b/src/Agent.Windows/Security/PathPolicy.cs index 3de4804..67c7340 100644 --- a/src/Agent.Windows/Security/PathPolicy.cs +++ b/src/Agent.Windows/Security/PathPolicy.cs @@ -3,23 +3,23 @@ namespace Agent.Windows.Security; [Flags] public enum FileCapability { None = 0, Read = 1, Write = 2, Execute = 4, Delete = 8 } -public sealed record FilePolicyEntry(string Path, FileCapability Capabilities); +public sealed record FilePolicyEntry(string Path, FileCapability Capabilities, string Origin = "unspecified"); public sealed class PathPolicy(IEnumerable<FilePolicyEntry> entries) { - private readonly IReadOnlyList<FilePolicyEntry> _entries = entries.Select(entry => entry with { Path = CanonicalPath.Resolve(entry.Path, requireExisting: false) }).ToArray(); + private readonly IReadOnlyList<FilePolicyEntry> _entries = entries.Select(entry => entry with { Path = ResolvedPath.Resolve(entry.Path, requireExisting: false) }).ToArray(); public bool IsAllowed(string path, FileCapability required) { - string canonical = CanonicalPath.Resolve(path, requireExisting: required is FileCapability.Read or FileCapability.Execute or FileCapability.Delete); + string canonical = ResolvedPath.Resolve(path, requireExisting: required is FileCapability.Read or FileCapability.Execute or FileCapability.Delete); return _entries.Any(entry => IsAtOrBelow(canonical, entry.Path) && (entry.Capabilities & required) == required); } public void Demand(string path, FileCapability required) { if (!IsAllowed(path, required)) - throw new SandboxPolicyException("policy.deny", $"'{path}' is not approved for {required}. Add the canonical path to sandbox.filesystem.{required.ToString().ToLowerInvariant()} and restart the server.", - "filesystem", Path.GetFullPath(path), required.ToString().ToLowerInvariant(), $"sandbox.filesystem.{required.ToString().ToLowerInvariant()}"); + throw new SandboxPolicyException("policy.deny", $"Resolved Path '{path}' is outside the Effective Sandbox Policy for {required}.", + "filesystem", Path.GetFullPath(path), required.ToString().ToLowerInvariant(), "Confirm a Workspace or Mount that grants the required Filesystem Capability, then restart the server."); } private static bool IsAtOrBelow(string candidate, string root) => @@ -27,7 +27,7 @@ public sealed class PathPolicy(IEnumerable<FilePolicyEntry> entries) candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); } -public static class CanonicalPath +public static class ResolvedPath { public static string Resolve(string path, bool requireExisting = true) { @@ -35,21 +35,34 @@ public static class CanonicalPath string fullPath = Path.GetFullPath(path); string? root = Path.GetPathRoot(fullPath); if (string.IsNullOrEmpty(root)) throw new SandboxPolicyException("path.invalid", "Path has no filesystem root."); + if (root.StartsWith("\\\\", StringComparison.Ordinal)) + throw new SandboxPolicyException("path.non_local", "Only local filesystem paths are supported.", "filesystem", fullPath, null, "Select a local Workspace, Mount, Runtime Root, or Executable."); string current = root; + var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase); foreach (string segment in fullPath[root.Length..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) { current = Path.Combine(current, segment); if (!File.Exists(current) && !Directory.Exists(current)) continue; - if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + while ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) { + string reparsePath = Path.GetFullPath(current); + if (!visited.Add(reparsePath)) + throw new SandboxPolicyException("path.reparse_cycle", $"Reparse resolution cycle detected at '{reparsePath}'.", "filesystem", reparsePath, null, "Remove the reparse cycle."); FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current); - string? target = info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? info.LinkTarget; - throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: component='{current}'. Approve the resolved canonical target{(string.IsNullOrWhiteSpace(target) ? string.Empty : $" '{Path.GetFullPath(target)}'")} instead.", - "filesystem", current, null, string.IsNullOrWhiteSpace(target) ? "sandbox.filesystem" : Path.GetFullPath(target)); + string? target; + try { target = info.ResolveLinkTarget(returnFinalTarget: false)?.FullName; } + catch (IOException ex) + { + throw new SandboxPolicyException("path.reparse_unresolved", $"Could not resolve reparse component '{current}': {ex.Message}", + "filesystem", current, null, "Repair or remove the reparse point."); + } + if (string.IsNullOrWhiteSpace(target)) + throw new SandboxPolicyException("path.reparse_unresolved", $"Could not resolve reparse component '{current}'.", "filesystem", current, null, "Repair or remove the reparse point."); + current = Path.GetFullPath(target); } } - if (requireExisting && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Approved path does not exist.", fullPath); - return Path.TrimEndingDirectorySeparator(fullPath); + if (requireExisting && !File.Exists(current) && !Directory.Exists(current)) throw new FileNotFoundException("Resolved Path does not exist.", current); + return Path.TrimEndingDirectorySeparator(current); } } diff --git a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs index 687af94..c3a19da 100644 --- a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs +++ b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs @@ -14,21 +14,27 @@ namespace Agent.Windows.Security; public sealed record RestrictedProcessResult(uint ProcessId, uint ExitCode, string StandardOutput, string StandardError); public sealed record SandboxJobLimits(uint MaxProcesses = 16, ulong MaxJobMemoryBytes = 4UL * 1024 * 1024 * 1024); -public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executablePolicy, ISecurityAuditSink audit, SecurityIdentifier? appContainerSid = null, SandboxJobLimits? jobLimits = null) +public sealed unsafe class RestrictedProcessLauncher(EntryExecutablePolicy executablePolicy, ISecurityAuditSink audit, SecurityIdentifier? appContainerSid = null, + SandboxJobLimits? jobLimits = null, bool allowNetwork = false) { - private const uint ProcessCreationChildProcessRestricted = 0x1; + private const uint ProcessCreationAllApplicationPackagesOptOut = 0x1; private readonly SandboxJobLimits _jobLimits = jobLimits ?? new(); public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout, - string? standardInput = null, Guid? sessionId = null) + string? standardInput = null, Guid? sessionId = null) => + Run(taskId, executable, arguments, workingDirectory, timeout, standardInput, sessionId, CancellationToken.None); + + public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout, + string? standardInput, Guid? sessionId, CancellationToken cancellationToken) { - VerifiedExecutable verified; - VerifiedExecutable? managedEntry = null; + string cwd = ResolvedPath.Resolve(workingDirectory); + VerifiedEntryExecutable verified; + string? managedEntry = null; try { - verified = executablePolicy.Verify(executable); - string? managedEntryPath = ExecutablePolicy.ManagedEntryPoint(verified.CanonicalPath, arguments); - if (managedEntryPath is not null) managedEntry = executablePolicy.Verify(managedEntryPath); + verified = executablePolicy.VerifyEntry(executable, cwd); + managedEntry = executablePolicy.ManagedEntryPoint(verified.ResolvedPath, arguments, cwd); + if (managedEntry is not null) executablePolicy.VerifyManagedEntryPoint(managedEntry); } catch (SandboxPolicyException ex) { @@ -36,7 +42,6 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable throw; } - string cwd = CanonicalPath.Resolve(workingDirectory); using FileStream stdin = CaptureFile(); using FileStream stdout = CaptureFile(); using FileStream stderr = CaptureFile(); @@ -63,18 +68,14 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable throw LastError("SetInformationJobObject"); nuint attributeSize = 0; - uint attributeCount = appContainerSid is null ? 2u : 3u; + uint attributeCount = appContainerSid is null ? 1u : 3u; _ = PInvoke.InitializeProcThreadAttributeList(default, attributeCount, 0, &attributeSize); LPPROC_THREAD_ATTRIBUTE_LIST attributes = (LPPROC_THREAD_ATTRIBUTE_LIST)NativeMemory.Alloc(attributeSize); void* sidBuffer = null; - void* internetClientSidBuffer = null; - void* privateNetworkSidBuffer = null; + var capabilityBuffers = new List<nint>(); try { if (!PInvoke.InitializeProcThreadAttributeList(attributes, attributeCount, 0, &attributeSize)) throw LastError("InitializeProcThreadAttributeList"); - uint childPolicy = ProcessCreationChildProcessRestricted; - if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, &childPolicy, sizeof(uint), null, null)) - throw LastError("UpdateProcThreadAttribute"); HANDLE[] inheritedHandles = [ new(stdin.SafeFileHandle.DangerousGetHandle()), @@ -95,17 +96,24 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable sidBuffer = NativeMemory.Alloc((nuint)sidBytes.Length); Marshal.Copy(sidBytes, 0, (nint)sidBuffer, sidBytes.Length); securityCapabilities.AppContainerSid = (nint)sidBuffer; - internetClientSidBuffer = AllocateSid(new SecurityIdentifier("S-1-15-3-1")); - privateNetworkSidBuffer = AllocateSid(new SecurityIdentifier("S-1-15-3-3")); - SID_AND_ATTRIBUTES* capabilities = stackalloc SID_AND_ATTRIBUTES[2]; - capabilities[0].Sid = new PSID(internetClientSidBuffer); - capabilities[0].Attributes = 0x00000004; - capabilities[1].Sid = new PSID(privateNetworkSidBuffer); - capabilities[1].Attributes = 0x00000004; + SecurityIdentifier[] capabilitySids = allowNetwork + ? [DeriveCapabilitySid("registryRead"), DeriveCapabilitySid("lpacInstrumentation"), new("S-1-15-3-1"), new("S-1-15-3-3")] + : [DeriveCapabilitySid("registryRead"), DeriveCapabilitySid("lpacInstrumentation")]; + SID_AND_ATTRIBUTES* capabilities = stackalloc SID_AND_ATTRIBUTES[capabilitySids.Length]; + for (int i = 0; i < capabilitySids.Length; i++) + { + void* buffer = AllocateSid(capabilitySids[i]); + capabilityBuffers.Add((nint)buffer); + capabilities[i].Sid = new PSID(buffer); + capabilities[i].Attributes = 0x00000004; + } securityCapabilities.Capabilities = (nint)capabilities; - securityCapabilities.CapabilityCount = 2; + securityCapabilities.CapabilityCount = (uint)capabilitySids.Length; if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, - &securityCapabilities, (nuint)sizeof(SecurityCapabilities), null, null)) throw LastError("UpdateProcThreadAttribute(SecurityCapabilities)"); + &securityCapabilities, (nuint)sizeof(SecurityCapabilities), null, null)) throw LpacFailure("UpdateProcThreadAttribute(SecurityCapabilities)", verified.ResolvedPath); + uint allApplicationPackagesPolicy = ProcessCreationAllApplicationPackagesOptOut; + if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + &allApplicationPackagesPolicy, sizeof(uint), null, null)) throw LpacFailure("UpdateProcThreadAttribute(AllApplicationPackagesPolicy)", verified.ResolvedPath); } STARTUPINFOEXW startup = default; startup.StartupInfo.cb = (uint)sizeof(STARTUPINFOEXW); @@ -115,28 +123,29 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable startup.StartupInfo.hStdError = inheritedHandles[2]; startup.lpAttributeList = attributes; PROCESS_INFORMATION process = default; - string commandLine = BuildCommandLine(verified.CanonicalPath, arguments); + string commandLine = BuildCommandLine(verified.ResolvedPath, arguments); Span<char> commandBuffer = new char[commandLine.Length + 1]; commandLine.AsSpan().CopyTo(commandBuffer); - if (!PInvoke.CreateProcess(null, ref commandBuffer, null, null, true, - PROCESS_CREATION_FLAGS.EXTENDED_STARTUPINFO_PRESENT | PROCESS_CREATION_FLAGS.CREATE_SUSPENDED, - null, cwd, in startup.StartupInfo, out process)) throw LastError("CreateProcess"); + char[] environmentBlock = BuildEnvironmentBlock(cwd); + fixed (char* environment = environmentBlock) + { + if (!PInvoke.CreateProcess(null, ref commandBuffer, null, null, true, + PROCESS_CREATION_FLAGS.EXTENDED_STARTUPINFO_PRESENT | PROCESS_CREATION_FLAGS.CREATE_SUSPENDED | + PROCESS_CREATION_FLAGS.CREATE_UNICODE_ENVIRONMENT, + environment, cwd, in startup.StartupInfo, out process)) throw LastError("CreateProcess"); + } try { using var processView = new SafeFileHandle(process.hProcess, ownsHandle: false); if (!PInvoke.AssignProcessToJobObject(job, processView)) throw LastError("AssignProcessToJobObject"); try { - VerifiedExecutable postCreate = executablePolicy.Verify(executable); - if (!string.Equals(postCreate.CanonicalPath, verified.CanonicalPath, StringComparison.OrdinalIgnoreCase) || + VerifiedEntryExecutable postCreate = executablePolicy.VerifyEntry(executable, cwd); + if (!string.Equals(postCreate.ResolvedPath, verified.ResolvedPath, StringComparison.OrdinalIgnoreCase) || !string.Equals(postCreate.Sha256, verified.Sha256, StringComparison.Ordinal)) throw new SandboxPolicyException("process.identity_changed", "Executable identity changed during process creation."); if (managedEntry is not null) - { - VerifiedExecutable postCreateManaged = executablePolicy.Verify(managedEntry.CanonicalPath); - if (!string.Equals(postCreateManaged.Sha256, managedEntry.Sha256, StringComparison.Ordinal)) - throw new SandboxPolicyException("process.managed_identity_changed", "Managed entry assembly identity changed during process creation."); - } + executablePolicy.VerifyManagedEntryPoint(managedEntry); } catch (SandboxPolicyException ex) { @@ -146,20 +155,41 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable }, SessionId: sessionId, ErrorCode: ex.Code)); throw; } + (bool tokenIsAppContainer, string? tokenAppContainerSid, bool processInJob) = InspectConfinement(processView, job); + if (appContainerSid is not null && (!tokenIsAppContainer || + !string.Equals(tokenAppContainerSid, appContainerSid.Value, StringComparison.Ordinal) || !processInJob)) + throw new SandboxPolicyException("process.lpac_mismatch", + "Entry Process did not receive the required LPAC identity and Job Object confinement.", + "process", verified.ResolvedPath, "execute", "Verify LPAC support on this Windows host; no fallback is permitted."); if (PInvoke.ResumeThread(process.hThread) == uint.MaxValue) throw LastError("ResumeThread"); var processAudit = new Dictionary<string, string> { - ["path"] = verified.CanonicalPath, ["sha256"] = verified.Sha256, ["child_process_policy"] = "restricted", - ["app_container_sid"] = appContainerSid?.Value ?? "none", ["network_capabilities"] = appContainerSid is null ? "none" : "internetClient,privateNetworkClientServer", + ["path"] = verified.ResolvedPath, ["sha256"] = verified.Sha256, ["entry_classification"] = verified.Classification.ToString(), + ["alias"] = verified.Alias ?? string.Empty, ["descendant_policy"] = "lpac_inherited", + ["app_container_sid"] = appContainerSid?.Value ?? "none", ["lpac"] = (appContainerSid is not null).ToString(), + ["token_is_app_container"] = tokenIsAppContainer.ToString(), ["token_app_container_sid"] = tokenAppContainerSid ?? "none", + ["process_in_job"] = processInJob.ToString(), ["all_application_packages_opt_out"] = (appContainerSid is not null).ToString(), + ["network_capabilities"] = appContainerSid is null || !allowNetwork ? "none" : "internetClient,privateNetworkClientServer", + ["baseline_capabilities"] = appContainerSid is null ? "none" : "registryRead,lpacInstrumentation", + ["environment_policy"] = "minimal_no_broker_credentials", + ["arguments"] = "redacted", ["argument_count"] = arguments.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), ["pid"] = process.dwProcessId.ToString() }; if (managedEntry is not null) { - processAudit["managed_entry_path"] = managedEntry.CanonicalPath; - processAudit["managed_entry_sha256"] = managedEntry.Sha256; + processAudit["managed_entry_path"] = ResolvedPath.Resolve(managedEntry); } audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit, SessionId: sessionId)); - WAIT_EVENT wait = PInvoke.WaitForSingleObject(process.hProcess, checked((uint)Math.Clamp(timeout.TotalMilliseconds, 1, uint.MaxValue - 1))); + long deadline = Environment.TickCount64 + checked((long)Math.Clamp(timeout.TotalMilliseconds, 1, int.MaxValue)); + WAIT_EVENT wait; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + long remaining = deadline - Environment.TickCount64; + if (remaining <= 0) { wait = WAIT_EVENT.WAIT_TIMEOUT; break; } + wait = PInvoke.WaitForSingleObject(process.hProcess, (uint)Math.Min(remaining, 100)); + if (wait != WAIT_EVENT.WAIT_TIMEOUT) break; + } if (wait == WAIT_EVENT.WAIT_TIMEOUT) { _ = PInvoke.TerminateJobObject(job, 1460); @@ -172,12 +202,20 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable { audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "observed", new Dictionary<string, string> { - ["code"] = "restricted_process_failure", ["pid"] = process.dwProcessId.ToString(), - ["child_process_policy"] = "restricted", ["exit_code"] = exitCode.ToString() - }, SessionId: sessionId, ErrorCode: "process.child_denied")); + ["code"] = "sandbox_process_failure", ["pid"] = process.dwProcessId.ToString(), + ["descendant_policy"] = "lpac_inherited", ["exit_code"] = exitCode.ToString() + }, SessionId: sessionId, ErrorCode: "process.nonzero_exit")); } return new(process.dwProcessId, exitCode, ReadCapture(stdout), ReadCapture(stderr)); } + catch (OperationCanceledException) + { + _ = PInvoke.TerminateJobObject(job, 1223); + _ = PInvoke.WaitForSingleObject(process.hProcess, 5000); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.cancel", "terminated", + new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString() }, SessionId: sessionId)); + throw; + } finally { PInvoke.CloseHandle(process.hThread); @@ -188,8 +226,7 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable { if (attributes.Value is not null) PInvoke.DeleteProcThreadAttributeList(attributes); NativeMemory.Free(sidBuffer); - NativeMemory.Free(internetClientSidBuffer); - NativeMemory.Free(privateNetworkSidBuffer); + foreach (nint buffer in capabilityBuffers) NativeMemory.Free((void*)buffer); NativeMemory.Free(attributes.Value); } } @@ -201,6 +238,34 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable int code = Marshal.GetLastWin32Error(); return new(code, $"{operation} failed with Win32 error {code}."); } + + private static SandboxPolicyException LpacFailure(string operation, string executable) + { + int code = Marshal.GetLastWin32Error(); + return new("process.lpac_unsupported", $"{operation} failed with Win32 error {code}; no fallback is permitted.", + "process", executable, "execute", "Use a supported Windows 11 host and verify LPAC capability derivation and process-attribute support."); + } + + private static (bool IsAppContainer, string? AppContainerSid, bool InJob) InspectConfinement(SafeFileHandle process, SafeFileHandle job) + { + if (!PInvoke.IsProcessInJob(process, job, out BOOL inJob)) throw LastError("IsProcessInJob"); + if (!PInvoke.OpenProcessToken(process, TOKEN_ACCESS_MASK.TOKEN_QUERY, out SafeFileHandle token)) throw LastError("OpenProcessToken"); + using (token) + { + Span<byte> flag = stackalloc byte[sizeof(uint)]; + if (!PInvoke.GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenIsAppContainer, flag, out _)) + throw LastError("GetTokenInformation(TokenIsAppContainer)"); + bool isAppContainer = MemoryMarshal.Read<uint>(flag) != 0; + _ = PInvoke.GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenAppContainerSid, Span<byte>.Empty, out uint sidInformationLength); + if (sidInformationLength < IntPtr.Size) throw LastError("GetTokenInformation(TokenAppContainerSid size)"); + Span<byte> sidInformation = sidInformationLength <= 256 ? stackalloc byte[(int)sidInformationLength] : new byte[sidInformationLength]; + if (!PInvoke.GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenAppContainerSid, sidInformation, out _)) + throw LastError("GetTokenInformation(TokenAppContainerSid)"); + nint sidPointer = MemoryMarshal.Read<nint>(sidInformation); + string? sid = sidPointer == 0 ? null : new SecurityIdentifier(sidPointer).Value; + return (isAppContainer, sid, inJob); + } + } private static FileStream CaptureFile() { string path = Path.Combine(Path.GetTempPath(), "agent-mcp-process-" + Guid.NewGuid().ToString("N") + ".tmp"); @@ -215,6 +280,20 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable Marshal.Copy(bytes, 0, (nint)buffer, bytes.Length); return buffer; } + + private static SecurityIdentifier DeriveCapabilitySid(string name) + { + if (!PInvoke.DeriveCapabilitySidsFromName(name, out PSID* groupSids, out uint groupCount, out PSID* capabilitySids, out uint capabilityCount) || capabilityCount != 1) + throw LastError($"DeriveCapabilitySidsFromName({name})"); + try { return new SecurityIdentifier((nint)capabilitySids[0].Value); } + finally + { + for (uint i = 0; i < groupCount; i++) _ = PInvoke.LocalFree(new HLOCAL((nint)groupSids[i].Value)); + for (uint i = 0; i < capabilityCount; i++) _ = PInvoke.LocalFree(new HLOCAL((nint)capabilitySids[i].Value)); + _ = PInvoke.LocalFree(new HLOCAL((nint)groupSids)); + _ = PInvoke.LocalFree(new HLOCAL((nint)capabilitySids)); + } + } private static void MakeInheritable(SafeFileHandle handle) { if (!PInvoke.SetHandleInformation(handle, (uint)HANDLE_FLAGS.HANDLE_FLAG_INHERIT, HANDLE_FLAGS.HANDLE_FLAG_INHERIT)) @@ -230,6 +309,45 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable return count > maxCharacters ? new string(buffer, 0, maxCharacters) + "\n[output truncated]" : new string(buffer, 0, count); } private static string BuildCommandLine(string executable, IReadOnlyList<string> arguments) => string.Join(' ', [Quote(executable), .. arguments.Select(Quote)]); + private static char[] BuildEnvironmentBlock(string cwd) + { + string systemRoot = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + var values = new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase) + { + ["ComSpec"] = Path.Combine(systemRoot, "System32", "cmd.exe"), + ["DOTNET_CLI_HOME"] = cwd, + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", + ["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0", + ["DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE"] = "true", + ["DOTNET_NOLOGO"] = "1", + ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1", + ["DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK"] = "true", + ["MSBuildEnableWorkloadResolver"] = "false", + ["MSBUILDDISABLENODEREUSE"] = "1", + ["NUMBER_OF_PROCESSORS"] = Environment.ProcessorCount.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["OS"] = "Windows_NT", + ["PATH"] = Path.Combine(systemRoot, "System32"), + ["PATHEXT"] = ".COM;.EXE;.BAT;.CMD", + ["PROCESSOR_ARCHITECTURE"] = RuntimeInformation.ProcessArchitecture.ToString().ToUpperInvariant(), + ["SystemDrive"] = Path.GetPathRoot(systemRoot)!.TrimEnd(Path.DirectorySeparatorChar), + ["SystemRoot"] = systemRoot, + ["TEMP"] = cwd, + ["TMP"] = cwd, + ["windir"] = systemRoot + }; + foreach (string name in new[] + { + "ALLUSERSPROFILE", "APPDATA", "CommonProgramFiles", "CommonProgramFiles(x86)", "CommonProgramW6432", + "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "ProgramData", "ProgramFiles", "ProgramFiles(x86)", + "ProgramW6432", "PUBLIC", "USERDOMAIN", "USERNAME", "USERPROFILE" + }) + { + string? value = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrEmpty(value)) values[name] = value; + } + string block = string.Concat(values.Select(pair => $"{pair.Key}={pair.Value}\0")) + "\0"; + return block.ToCharArray(); + } private static string Quote(string value) { if (value.Length > 0 && !value.Any(static character => char.IsWhiteSpace(character) || character == '"')) return value; diff --git a/src/Agent.Windows/Security/TaskAclManager.cs b/src/Agent.Windows/Security/TaskAclManager.cs index 2b091a6..be0080d 100644 --- a/src/Agent.Windows/Security/TaskAclManager.cs +++ b/src/Agent.Windows/Security/TaskAclManager.cs @@ -10,27 +10,64 @@ public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurity { public AclSnapshot Grant(Guid taskId, string path, FileCapability capabilities) { - string canonical = CanonicalPath.Resolve(path); + string canonical = ResolvedPath.Resolve(path); FileSystemSecurity security = ReadSecurity(canonical); byte[] original = security.GetSecurityDescriptorBinaryForm(); Guid? journalEntry = journal?.Prepare("acl", canonical, new Dictionary<string, string> { ["security_descriptor"] = Convert.ToBase64String(original) }); - FileSystemRights rights = ToRights(capabilities); - if (rights == 0) throw new ArgumentOutOfRangeException(nameof(capabilities)); bool directory = Directory.Exists(canonical); - var rule = new FileSystemAccessRule(appContainerSid, rights, - directory ? InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit : InheritanceFlags.None, - PropagationFlags.None, AccessControlType.Allow); - security.AddAccessRule(rule); - WriteSecurity(canonical, security); - if (journalEntry is not null) journal!.MarkApplied(journalEntry.Value); + FileSystemRights auditRights; + if (directory) + { + FileSystemRights directoryRights = ToDirectoryRights(capabilities); + FileSystemRights fileRights = ToFileRights(capabilities); + if (directoryRights == 0 || fileRights == 0) throw new ArgumentOutOfRangeException(nameof(capabilities)); + security.AddAccessRule(new FileSystemAccessRule(appContainerSid, directoryRights, + InheritanceFlags.ContainerInherit, PropagationFlags.None, AccessControlType.Allow)); + security.AddAccessRule(new FileSystemAccessRule(appContainerSid, fileRights, + InheritanceFlags.ObjectInherit, PropagationFlags.InheritOnly, AccessControlType.Allow)); + auditRights = directoryRights | fileRights; + } + else + { + auditRights = ToFileRights(capabilities); + if (auditRights == 0) throw new ArgumentOutOfRangeException(nameof(capabilities)); + security.AddAccessRule(new FileSystemAccessRule(appContainerSid, auditRights, + InheritanceFlags.None, PropagationFlags.None, AccessControlType.Allow)); + } + try + { + WriteSecurity(canonical, security); + if (journalEntry is not null) journal!.MarkApplied(journalEntry.Value); + } + catch (Exception applyFailure) + { + try + { + byte[] current = ReadSecurity(canonical).GetSecurityDescriptorBinaryForm(); + if (!current.AsSpan().SequenceEqual(original)) + { + FileSystemSecurity rollback = Directory.Exists(canonical) ? new DirectorySecurity() : new FileSecurity(); + rollback.SetSecurityDescriptorBinaryForm(original, AccessControlSections.Access); + WriteSecurity(canonical, rollback); + byte[] restored = ReadSecurity(canonical).GetSecurityDescriptorBinaryForm(); + if (!restored.AsSpan().SequenceEqual(original)) throw new InvalidOperationException("ACL rollback verification failed."); + } + if (journalEntry is not null) journal!.MarkCleaned(journalEntry.Value); + } + catch (Exception rollbackFailure) + { + throw new AggregateException("ACL apply failed and immediate rollback did not reach a verified clean state.", applyFailure, rollbackFailure); + } + throw; + } audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.apply", "applied", new Dictionary<string, string> { ["path"] = canonical, ["sid"] = appContainerSid.Value, - ["rights"] = rights.ToString(), + ["rights"] = auditRights.ToString(), ["scope"] = directory ? "root_inheritance" : "file" })); return new(canonical, original, journalEntry); @@ -55,12 +92,23 @@ public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurity else FileSystemAclExtensions.SetAccessControl(new FileInfo(path), (FileSecurity)security); } - private static FileSystemRights ToRights(FileCapability capabilities) + private static FileSystemRights ToFileRights(FileCapability capabilities) { FileSystemRights rights = 0; - if ((capabilities & FileCapability.Read) != 0) rights |= FileSystemRights.ReadAndExecute | FileSystemRights.ListDirectory; + if ((capabilities & FileCapability.Read) != 0) + rights |= FileSystemRights.ReadData | FileSystemRights.ReadAttributes | FileSystemRights.ReadExtendedAttributes | FileSystemRights.ReadPermissions; if ((capabilities & FileCapability.Write) != 0) rights |= FileSystemRights.Write; - if ((capabilities & FileCapability.Execute) != 0) rights |= FileSystemRights.ReadAndExecute; + if ((capabilities & FileCapability.Execute) != 0) rights |= FileSystemRights.ExecuteFile | FileSystemRights.ReadData | FileSystemRights.ReadAttributes; + if ((capabilities & FileCapability.Delete) != 0) rights |= FileSystemRights.Delete; + return rights; + } + + private static FileSystemRights ToDirectoryRights(FileCapability capabilities) + { + FileSystemRights rights = FileSystemRights.Traverse | FileSystemRights.ReadAttributes | FileSystemRights.ReadPermissions; + if ((capabilities & FileCapability.Read) != 0) rights |= FileSystemRights.ListDirectory | FileSystemRights.ReadExtendedAttributes; + if ((capabilities & FileCapability.Write) != 0) rights |= FileSystemRights.CreateFiles | FileSystemRights.CreateDirectories | + FileSystemRights.WriteAttributes | FileSystemRights.WriteExtendedAttributes; if ((capabilities & FileCapability.Delete) != 0) rights |= FileSystemRights.Delete | FileSystemRights.DeleteSubdirectoriesAndFiles; return rights; } diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs index 5cbf853..af4c906 100644 --- a/src/Agent.Windows/Tools/WindowsToolBackend.cs +++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs @@ -10,12 +10,13 @@ using Agent.Windows.Security; namespace Agent.Windows.Tools; -public sealed record WindowsSandboxSettings( +public sealed record EffectiveSandboxPolicy( IReadOnlyList<FilePolicyEntry> Files, - IReadOnlyList<ExecutableApproval> Executables, - bool AllowShell, + IReadOnlyList<ApprovedEntryExecutable> EntryExecutables, + IReadOnlyList<string> RuntimeRoots, + IReadOnlyList<string> ArtifactRoots, SandboxJobLimits JobLimits, - string NetworkMode, + string NetworkAccess, IReadOnlyList<(string Host, IReadOnlyList<int> Ports)> NetworkAllowances, string JournalDirectory, string BackupDirectory, @@ -23,17 +24,22 @@ public sealed record WindowsSandboxSettings( long WholeReadLimitBytes, int SearchResultLimit, int TailLineLimit, - int ProcessTimeoutSeconds = 300); + int ProcessTimeoutSeconds = 300, + string ResourceProfile = "normal", + IReadOnlyList<string>? BaselineCapabilities = null); -public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurityAuditSink audit, ITaskUpdateHandler? taskUpdates = null) : IInternalToolBackend +public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurityAuditSink audit, ITaskUpdateHandler? taskUpdates = null) : IInternalToolBackend { private readonly ConcurrentDictionary<Guid, TaskContext> _contexts = new(); private readonly PathPolicy _paths = new(settings.Files); - private readonly ExecutablePolicy _executables = new(settings.Executables, settings.AllowShell); + private readonly EntryExecutablePolicy _entryExecutables = new(settings.EntryExecutables, settings.RuntimeRoots, settings.ArtifactRoots); private readonly ITaskUpdateHandler _taskUpdates = taskUpdates ?? new TaskGraphToolHandler(); public async Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) { + if (settings.BaselineCapabilities is not null && + !settings.BaselineCapabilities.SequenceEqual(["registryRead", "lpacInstrumentation"], StringComparer.Ordinal)) + throw new InvalidOperationException("Effective Sandbox Policy baseline capabilities do not match the fixed LPAC baseline."); if (_contexts.Count != 0) throw new InvalidOperationException("Only one Task Resource scope may be active."); AppContainerProfile profile = AppContainerProfile.EnsureFixed(); TaskResourceJournal journal = TaskResourceJournal.Create(settings.JournalDirectory, taskId); @@ -42,23 +48,29 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit _taskUpdates.BeginTask(taskId, sessionId); try { - foreach ((string path, FileCapability capabilities) in settings.Files - .GroupBy(static entry => CanonicalPath.Resolve(entry.Path, false), StringComparer.OrdinalIgnoreCase) - .Select(static group => (group.Key, group.Aggregate(FileCapability.None, static (value, item) => value | item.Capabilities)))) + foreach ((string path, FileCapability capabilities, bool osRuntimeRoot) in settings.Files + .GroupBy(static entry => ResolvedPath.Resolve(entry.Path, false), StringComparer.OrdinalIgnoreCase) + .Select(static group => (group.Key, + group.Aggregate(FileCapability.None, static (value, item) => value | item.Capabilities), + group.All(static item => item.Origin == "os_runtime_root")))) { + // System32 has a protected OS-owned DACL. Its existing LPAC access is verified by + // real process-tree tests; changing or unprotecting that DACL is forbidden. + if (osRuntimeRoot) continue; // OS/runtime executables may already grant AppContainer execute through their protected system ACL. // Do not rewrite a file ACL when execute is the only requested capability; CreateProcess remains - // constrained by AppContainer access plus canonical path/SHA-256 approval. + // constrained by LPAC access plus Resolved Path/SHA-256 Entry approval. if (File.Exists(path) && capabilities == FileCapability.Execute) continue; // A single inheritable ACE on an approved directory covers existing and newly-created // descendants. Snapshotting and rewriting every child ACL is both redundant and // prohibitively expensive for runtime trees such as Python installations. context.Acls.Add(acl.Grant(taskId, path, capabilities)); } - IReadOnlyList<NetworkEndpoint> allowances = settings.NetworkMode == "allow" + IReadOnlyList<NetworkEndpoint> allowances = settings.NetworkAccess != "none" ? await NetworkAllowanceSnapshot.ResolveAsync(settings.NetworkAllowances, cancellationToken).ConfigureAwait(false) : []; - context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit, sessionId); + context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit, sessionId, + enableLoopback: settings.NetworkAccess != "none"); if (!_contexts.TryAdd(taskId, context)) throw new InvalidOperationException("Task context already exists."); audit.Write(new(DateTimeOffset.UtcNow, taskId, "task.start", "running", new Dictionary<string, string>(), SessionId: sessionId)); } @@ -82,7 +94,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit "file_search" => Task.FromResult(Search(arguments)), "file_read" => Task.FromResult(Read(arguments)), "file_edit" => Task.FromResult(Edit(context, arguments)), - "process_run" => Task.FromResult(RunProcess(context, arguments)), + "process_run" => Task.FromResult(RunProcess(context, arguments, cancellationToken)), "task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)), "dir" => Task.FromResult(DirectoryOp(context, arguments)), _ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.") @@ -128,7 +140,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit { string root = arguments.GetProperty("root").GetString()!; _paths.Demand(root, FileCapability.Read); - string canonical = CanonicalPath.Resolve(root); + string canonical = ResolvedPath.Resolve(root); string mode = arguments.GetProperty("mode").GetString()!; string query = arguments.GetProperty("query").GetString()!; int max = arguments.TryGetProperty("max_results", out JsonElement maxElement) ? maxElement.GetInt32() : 100; @@ -164,10 +176,10 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit { string path = arguments.GetProperty("path").GetString()!; _paths.Demand(path, FileCapability.Read); - string canonical = CanonicalPath.Resolve(path); + string canonical = ResolvedPath.Resolve(path); string mode = arguments.GetProperty("mode").GetString()!; FileInfo info = new(canonical); - if (mode == "metadata") return Success(JsonSerializer.Serialize(new { path = canonical, info.Length, info.LastWriteTimeUtc, sha256 = ExecutablePolicy.ComputeSha256(canonical) })); + if (mode == "metadata") return Success(JsonSerializer.Serialize(new { path = canonical, info.Length, info.LastWriteTimeUtc, sha256 = EntryExecutablePolicy.ComputeSha256(canonical) })); if (mode == "whole") { if (info.Length > settings.WholeReadLimitBytes) throw new IOException("Whole read exceeds configured limit; use range mode."); @@ -245,7 +257,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit if (operation == "list") { _paths.Demand(path, FileCapability.Read); - string canonical = CanonicalPath.Resolve(path); + string canonical = ResolvedPath.Resolve(path); var entries = Directory.EnumerateFileSystemEntries(canonical, "*", SearchOption.TopDirectoryOnly) .Select(Path.GetFullPath) .ToArray(); @@ -254,20 +266,20 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit throw new ToolValidationException($"Unsupported dir operation '{operation}'."); } - private ToolExecutionResult RunProcess(TaskContext context, JsonElement arguments) + private ToolExecutionResult RunProcess(TaskContext context, JsonElement arguments, CancellationToken cancellationToken) { Guid taskId = context.Journal.TaskId; string executable = arguments.GetProperty("executable").GetString()!; string cwd = arguments.GetProperty("cwd").GetString()!; - _paths.Demand(executable, FileCapability.Execute); _paths.Demand(cwd, FileCapability.Read); string[] args = arguments.GetProperty("args").EnumerateArray().Select(static item => item.GetString()!).ToArray(); - string? managedEntryPoint = ExecutablePolicy.ManagedEntryPoint(executable, args); + string? managedEntryPoint = _entryExecutables.ManagedEntryPoint(executable, args, cwd); if (managedEntryPoint is not null) _paths.Demand(managedEntryPoint, FileCapability.Execute); int seconds = ResolveProcessTimeout(arguments, settings.ProcessTimeoutSeconds); string? stdin = arguments.TryGetProperty("stdin", out JsonElement stdinElement) && stdinElement.ValueKind == JsonValueKind.String ? stdinElement.GetString() : null; - RestrictedProcessResult result = new RestrictedProcessLauncher(_executables, audit, AppContainerProfile.EnsureFixed().Sid, settings.JobLimits) - .Run(taskId, executable, args, cwd, TimeSpan.FromSeconds(seconds), stdin, context.SessionId); + RestrictedProcessResult result = new RestrictedProcessLauncher(_entryExecutables, audit, AppContainerProfile.EnsureFixed().Sid, settings.JobLimits, + allowNetwork: settings.NetworkAccess != "none") + .Run(taskId, executable, args, cwd, TimeSpan.FromSeconds(seconds), stdin, context.SessionId, cancellationToken); string output = $"Process exited with code {result.ExitCode} (PID {result.ProcessId}).\nstdout:\n{result.StandardOutput}\nstderr:\n{result.StandardError}"; return new(result.ExitCode == 0, output, true, result.ExitCode == 0 ? null : $"process:{executable}:{result.ExitCode}"); @@ -278,7 +290,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit int seconds = arguments.TryGetProperty("timeout_seconds", out JsonElement timeout) ? timeout.GetInt32() : configuredTimeoutSeconds; if (seconds <= 0) throw new ToolValidationException("timeout_seconds must be positive."); if (seconds > configuredTimeoutSeconds) - throw new ToolValidationException($"timeout_seconds exceeds configured sandbox.resources.process_timeout_seconds ({configuredTimeoutSeconds})."); + throw new ToolValidationException($"timeout_seconds exceeds configured sandbox.resources.execution_timeout_seconds ({configuredTimeoutSeconds})."); return seconds; } |
