From b07b41cd435991cd254957af79055080c85b346e Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 9 Aug 2026 21:08:20 +0900 Subject: feat: AI Agent Sandbox MCP Server v1を実装 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specifications、Design、ImplementationPlanの優先順位を整理し、単一Task実行モデル、typed option優先、設定可能なresource上限、local Ollamaを必須とするRelease Gateを文書化。 .NET 10/C# 14の5 production project、3 test project、E2E Clientを構築。typed YAML、en/ja locale、fixed/user prompt、stdioおよびloopback限定Streamable HTTP、agent_run/agent_status、Session、Ollama text/tool/Vision、TaskGraphとtoken accountingを実装。 Windows sandboxとしてfixed AppContainer、canonical/reparse検査、能力別ACL、path+SHA-256 executable approval、child-process restriction、Job Object、task journal、atomic file transaction、crash recoveryを実装。WFPのIPv4/IPv6 default denyとexact allow、dynamic session、temporary loopback exemption、管理者/BFE診断を追加。 Security/Recovery test、MCP/Ollama integration test、stdio/HTTP Operational E2E、Release Bundle生成・checksum・manifest・fresh extraction検査を追加。Requirement TraceabilityとPhase 0〜11の進捗、初回セットアップ可能なREADMEを整備。 Debug/Release/publish/objをrepository rootのbuild/へ集約し、旧project-local bin/objへの依存を除去。artifacts/とbuild/をGit管理外に設定し、clean checkoutでもsolution buildからE2E Clientまで生成可能にした。 --- src/Agent.App/Agent.App.csproj | 24 ++ src/Agent.App/Cli/CommandLine.cs | 39 ++++ src/Agent.App/Configuration/AgentConfiguration.cs | 76 ++++++ .../Configuration/ConfigurationException.cs | 4 + src/Agent.App/Configuration/ConfigurationLoader.cs | 85 +++++++ .../Configuration/OllamaSettingsFactory.cs | 39 ++++ src/Agent.App/Logging/JsonLinesRuntimeObserver.cs | 37 +++ src/Agent.App/Logging/RollingFileLog.cs | 55 +++++ src/Agent.App/Program.cs | 130 +++++++++++ src/Agent.Core/Agent.Core.csproj | 8 + src/Agent.Core/AssemblyMarker.cs | 3 + src/Agent.Core/Localization/LanguageContext.cs | 3 + src/Agent.Core/Localization/LocaleCatalog.cs | 97 ++++++++ src/Agent.Core/Localization/LocaleException.cs | 4 + src/Agent.Core/Ollama/IOllamaClient.cs | 11 + src/Agent.Core/Ollama/OllamaClient.cs | 92 ++++++++ src/Agent.Core/Ollama/OllamaException.cs | 7 + src/Agent.Core/Ollama/OllamaModels.cs | 46 ++++ src/Agent.Core/Ollama/OllamaSettings.cs | 10 + src/Agent.Core/Prompts/FixedPrompt.cs | 16 ++ src/Agent.Core/Prompts/FixedSystemPrompt.md | 4 + src/Agent.Core/Prompts/PromptComposer.cs | 36 +++ src/Agent.Core/Runtime/AgentRuntime.cs | 165 +++++++++++++ src/Agent.Core/Runtime/AgentRuntimeSettings.cs | 10 + src/Agent.Core/Runtime/IAgentRuntimeObserver.cs | 19 ++ src/Agent.Core/Sessions/AgentSession.cs | 26 +++ src/Agent.Core/Sessions/ConversationMessage.cs | 3 + src/Agent.Core/Sessions/SessionStore.cs | 19 ++ src/Agent.Core/Tasks/AgentTaskState.cs | 11 + src/Agent.Core/Tasks/TaskRegistry.cs | 115 +++++++++ src/Agent.Core/Tasks/TaskSnapshot.cs | 16 ++ src/Agent.Core/Tools/InternalToolCatalog.cs | 32 +++ src/Agent.Core/Tools/InternalToolDefinition.cs | 5 + src/Agent.Core/Tools/InternalToolDispatcher.cs | 29 +++ src/Agent.Core/Tools/InternalToolValidator.cs | 86 +++++++ src/Agent.Core/Tools/TaskGraphToolHandler.cs | 123 ++++++++++ src/Agent.Core/Tools/ToolPolicy.cs | 31 +++ src/Agent.Core/Tools/ToolUseGuard.cs | 22 ++ src/Agent.Core/Tools/ToolValidationException.cs | 3 + src/Agent.Mcp/Agent.Mcp.csproj | 12 + src/Agent.Mcp/AgentTools.cs | 90 ++++++++ src/Agent.Mcp/AssemblyMarker.cs | 3 + src/Agent.Mcp/McpHost.cs | 136 +++++++++++ src/Agent.Mcp/McpImageInput.cs | 12 + src/Agent.Mcp/QueuedTaskWorker.cs | 18 ++ src/Agent.Mcp/VisionLimits.cs | 3 + src/Agent.Planner/Agent.Planner.csproj | 1 + src/Agent.Planner/AssemblyMarker.cs | 3 + src/Agent.Planner/PlannerTask.cs | 18 ++ src/Agent.Planner/PlannerTaskState.cs | 11 + src/Agent.Planner/PlannerValidationException.cs | 3 + src/Agent.Planner/TaskGraph.cs | 179 ++++++++++++++ src/Agent.Planner/TokenBudgetClass.cs | 31 +++ src/Agent.Planner/TokenEstimate.cs | 15 ++ src/Agent.Windows/Agent.Windows.csproj | 17 ++ src/Agent.Windows/AssemblyMarker.cs | 3 + .../Diagnostics/WindowsPlatformCheck.cs | 50 ++++ src/Agent.Windows/NativeMethods.txt | 46 ++++ src/Agent.Windows/Network/LoopbackExemption.cs | 123 ++++++++++ src/Agent.Windows/Network/TaskNetworkPolicy.cs | 73 ++++++ src/Agent.Windows/Network/WfpDynamicSession.cs | 208 +++++++++++++++++ src/Agent.Windows/Recovery/RecoveryManager.cs | 114 +++++++++ src/Agent.Windows/Recovery/TaskResourceJournal.cs | 108 +++++++++ .../Recovery/TransactionalFileEditor.cs | 141 +++++++++++ src/Agent.Windows/Security/AppContainerProfile.cs | 41 ++++ src/Agent.Windows/Security/ExecutablePolicy.cs | 51 ++++ src/Agent.Windows/Security/PathPolicy.cs | 52 +++++ .../Security/RestrictedProcessLauncher.cs | 255 ++++++++++++++++++++ src/Agent.Windows/Security/SecurityAudit.cs | 29 +++ src/Agent.Windows/Security/TaskAclManager.cs | 76 ++++++ src/Agent.Windows/Security/UserOnlyDirectory.cs | 20 ++ src/Agent.Windows/Tools/WindowsToolBackend.cs | 257 +++++++++++++++++++++ 72 files changed, 3740 insertions(+) create mode 100644 src/Agent.App/Agent.App.csproj create mode 100644 src/Agent.App/Cli/CommandLine.cs create mode 100644 src/Agent.App/Configuration/AgentConfiguration.cs create mode 100644 src/Agent.App/Configuration/ConfigurationException.cs create mode 100644 src/Agent.App/Configuration/ConfigurationLoader.cs create mode 100644 src/Agent.App/Configuration/OllamaSettingsFactory.cs create mode 100644 src/Agent.App/Logging/JsonLinesRuntimeObserver.cs create mode 100644 src/Agent.App/Logging/RollingFileLog.cs create mode 100644 src/Agent.App/Program.cs create mode 100644 src/Agent.Core/Agent.Core.csproj create mode 100644 src/Agent.Core/AssemblyMarker.cs create mode 100644 src/Agent.Core/Localization/LanguageContext.cs create mode 100644 src/Agent.Core/Localization/LocaleCatalog.cs create mode 100644 src/Agent.Core/Localization/LocaleException.cs create mode 100644 src/Agent.Core/Ollama/IOllamaClient.cs create mode 100644 src/Agent.Core/Ollama/OllamaClient.cs create mode 100644 src/Agent.Core/Ollama/OllamaException.cs create mode 100644 src/Agent.Core/Ollama/OllamaModels.cs create mode 100644 src/Agent.Core/Ollama/OllamaSettings.cs create mode 100644 src/Agent.Core/Prompts/FixedPrompt.cs create mode 100644 src/Agent.Core/Prompts/FixedSystemPrompt.md create mode 100644 src/Agent.Core/Prompts/PromptComposer.cs create mode 100644 src/Agent.Core/Runtime/AgentRuntime.cs create mode 100644 src/Agent.Core/Runtime/AgentRuntimeSettings.cs create mode 100644 src/Agent.Core/Runtime/IAgentRuntimeObserver.cs create mode 100644 src/Agent.Core/Sessions/AgentSession.cs create mode 100644 src/Agent.Core/Sessions/ConversationMessage.cs create mode 100644 src/Agent.Core/Sessions/SessionStore.cs create mode 100644 src/Agent.Core/Tasks/AgentTaskState.cs create mode 100644 src/Agent.Core/Tasks/TaskRegistry.cs create mode 100644 src/Agent.Core/Tasks/TaskSnapshot.cs create mode 100644 src/Agent.Core/Tools/InternalToolCatalog.cs create mode 100644 src/Agent.Core/Tools/InternalToolDefinition.cs create mode 100644 src/Agent.Core/Tools/InternalToolDispatcher.cs create mode 100644 src/Agent.Core/Tools/InternalToolValidator.cs create mode 100644 src/Agent.Core/Tools/TaskGraphToolHandler.cs create mode 100644 src/Agent.Core/Tools/ToolPolicy.cs create mode 100644 src/Agent.Core/Tools/ToolUseGuard.cs create mode 100644 src/Agent.Core/Tools/ToolValidationException.cs create mode 100644 src/Agent.Mcp/Agent.Mcp.csproj create mode 100644 src/Agent.Mcp/AgentTools.cs create mode 100644 src/Agent.Mcp/AssemblyMarker.cs create mode 100644 src/Agent.Mcp/McpHost.cs create mode 100644 src/Agent.Mcp/McpImageInput.cs create mode 100644 src/Agent.Mcp/QueuedTaskWorker.cs create mode 100644 src/Agent.Mcp/VisionLimits.cs create mode 100644 src/Agent.Planner/Agent.Planner.csproj create mode 100644 src/Agent.Planner/AssemblyMarker.cs create mode 100644 src/Agent.Planner/PlannerTask.cs create mode 100644 src/Agent.Planner/PlannerTaskState.cs create mode 100644 src/Agent.Planner/PlannerValidationException.cs create mode 100644 src/Agent.Planner/TaskGraph.cs create mode 100644 src/Agent.Planner/TokenBudgetClass.cs create mode 100644 src/Agent.Planner/TokenEstimate.cs create mode 100644 src/Agent.Windows/Agent.Windows.csproj create mode 100644 src/Agent.Windows/AssemblyMarker.cs create mode 100644 src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs create mode 100644 src/Agent.Windows/NativeMethods.txt create mode 100644 src/Agent.Windows/Network/LoopbackExemption.cs create mode 100644 src/Agent.Windows/Network/TaskNetworkPolicy.cs create mode 100644 src/Agent.Windows/Network/WfpDynamicSession.cs create mode 100644 src/Agent.Windows/Recovery/RecoveryManager.cs create mode 100644 src/Agent.Windows/Recovery/TaskResourceJournal.cs create mode 100644 src/Agent.Windows/Recovery/TransactionalFileEditor.cs create mode 100644 src/Agent.Windows/Security/AppContainerProfile.cs create mode 100644 src/Agent.Windows/Security/ExecutablePolicy.cs create mode 100644 src/Agent.Windows/Security/PathPolicy.cs create mode 100644 src/Agent.Windows/Security/RestrictedProcessLauncher.cs create mode 100644 src/Agent.Windows/Security/SecurityAudit.cs create mode 100644 src/Agent.Windows/Security/TaskAclManager.cs create mode 100644 src/Agent.Windows/Security/UserOnlyDirectory.cs create mode 100644 src/Agent.Windows/Tools/WindowsToolBackend.cs (limited to 'src') diff --git a/src/Agent.App/Agent.App.csproj b/src/Agent.App/Agent.App.csproj new file mode 100644 index 0000000..a42ebad --- /dev/null +++ b/src/Agent.App/Agent.App.csproj @@ -0,0 +1,24 @@ + + + Exe + net10.0-windows10.0.26100.0 + agent-mcp + Agent.App + 1.0.0 + + + + + + + + + + + + + + + + + diff --git a/src/Agent.App/Cli/CommandLine.cs b/src/Agent.App/Cli/CommandLine.cs new file mode 100644 index 0000000..df65080 --- /dev/null +++ b/src/Agent.App/Cli/CommandLine.cs @@ -0,0 +1,39 @@ +namespace Agent.App.Cli; + +public enum AppCommand { Serve, Check } + +public sealed record CommandLine(AppCommand Command, string? ConfigPath, string? Language, string? Transport) +{ + public static CommandLine Parse(string[] args) + { + if (args.Length == 0) throw new CommandLineException("Expected 'serve' or 'check'."); + AppCommand command = args[0] switch + { + "serve" => AppCommand.Serve, + "check" => AppCommand.Check, + _ => throw new CommandLineException("Expected 'serve' or 'check'.") + }; + + string? config = null; + string? language = null; + string? transport = null; + for (int i = 1; i < args.Length; i++) + { + string option = args[i]; + if (i + 1 >= args.Length) throw new CommandLineException($"Missing value for '{option}'."); + string value = args[++i]; + 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}'."); + } + } + return new CommandLine(command, config, language, transport); + } +} + +public sealed class CommandLineException(string message) : Exception(message); diff --git a/src/Agent.App/Configuration/AgentConfiguration.cs b/src/Agent.App/Configuration/AgentConfiguration.cs new file mode 100644 index 0000000..800286b --- /dev/null +++ b/src/Agent.App/Configuration/AgentConfiguration.cs @@ -0,0 +1,76 @@ +namespace Agent.App.Configuration; + +public sealed class AgentConfiguration +{ + public int SchemaVersion { get; set; } + public ApplicationOptions Application { get; set; } = new(); + public McpOptions Mcp { get; set; } = new(); + public RuntimeOptions Agent { get; set; } = new(); + public OllamaConfiguration Ollama { get; set; } = new(); + public PlannerOptions Planner { get; set; } = new(); + public SandboxOptions Sandbox { get; set; } = new(); + public FileEditOptions FileEdit { get; set; } = new(); + public LimitOptions Limits { get; set; } = new(); + public LocalizationOptions Localization { get; set; } = new(); + public SkillOptions Skills { get; set; } = new(); + public LoggingOptions Logging { get; set; } = new(); +} + +public sealed class ApplicationOptions { public string Language { get; set; } = "en"; } + +public sealed class McpOptions +{ + public string Transport { get; set; } = "stdio"; + public HttpOptions Http { get; set; } = new(); + public ImageOptions Images { get; set; } = new(); +} + +public sealed class HttpOptions { public string Host { get; set; } = "127.0.0.1"; public int Port { get; set; } = 8080; } +public sealed class ImageOptions { public int MaxCount { get; set; } = 4; public long MaxBytesEach { get; set; } = 10 * 1024 * 1024; public long MaxTotalBytes { get; set; } = 20 * 1024 * 1024; } +public sealed class RuntimeOptions { public string UserSystemPrompt { get; set; } = "prompts/user-system.md"; public int MaxIterations { get; set; } = 128; public int SameFailureLimit { get; set; } = 5; } + +public sealed class OllamaConfiguration +{ + public string Endpoint { get; set; } = "http://127.0.0.1:11434"; + public string Model { get; set; } = string.Empty; + public bool Think { get; set; } = true; + public string KeepAlive { get; set; } = "5m"; + public OllamaGenerationOptions Options { get; set; } = new(); + public Dictionary ExtraOptions { get; set; } = new(StringComparer.Ordinal); +} + +public sealed class OllamaGenerationOptions +{ + public int? NumCtx { get; set; } + public int? NumKeep { get; set; } + public int? NumPredict { get; set; } + public double? Temperature { get; set; } + public int? Seed { get; set; } + public int? TopK { get; set; } + public double? TopP { get; set; } + public double? MinP { get; set; } + public double? TypicalP { get; set; } + public double? TfsZ { get; set; } + public int? RepeatLastN { get; set; } + public double? RepeatPenalty { get; set; } + public double? PresencePenalty { get; set; } + public double? FrequencyPenalty { get; set; } + public int? Mirostat { get; set; } + public double? MirostatTau { get; set; } + public double? MirostatEta { get; set; } + public List Stop { get; set; } = []; +} + +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 Read { get; set; } = []; public List Write { get; set; } = []; public List Execute { get; set; } = []; public List Delete { get; set; } = []; } +public sealed class ProcessOptions { public bool AllowShell { get; set; } = true; public List 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 Allow { get; set; } = []; } +public sealed class NetworkAllowance { public string Host { get; set; } = string.Empty; public List Ports { get; set; } = []; } +public sealed class ResourceOptions { public int MemoryMb { get; set; } = 4096; public int ProcessTimeoutSeconds { get; set; } = 1800; } +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"; } +public sealed class SkillOptions { public string Directory { get; set; } = "skills"; } +public sealed class LoggingOptions { public string Directory { get; set; } = "logs"; public long MaxFileBytes { get; set; } = 10 * 1024 * 1024; public int RetainedFiles { get; set; } = 5; } diff --git a/src/Agent.App/Configuration/ConfigurationException.cs b/src/Agent.App/Configuration/ConfigurationException.cs new file mode 100644 index 0000000..67f38a5 --- /dev/null +++ b/src/Agent.App/Configuration/ConfigurationException.cs @@ -0,0 +1,4 @@ +namespace Agent.App.Configuration; + +public sealed class ConfigurationException(string message, Exception? innerException = null) + : Exception(message, innerException); diff --git a/src/Agent.App/Configuration/ConfigurationLoader.cs b/src/Agent.App/Configuration/ConfigurationLoader.cs new file mode 100644 index 0000000..b6c4c2d --- /dev/null +++ b/src/Agent.App/Configuration/ConfigurationLoader.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Text.RegularExpressions; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Agent.App.Configuration; + +public static partial class ConfigurationLoader +{ + public static AgentConfiguration Load(string path) + { + string absolutePath = Path.GetFullPath(path); + if (!File.Exists(absolutePath)) + { + throw new ConfigurationException($"Configuration file does not exist: {absolutePath}"); + } + + try + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .Build(); + AgentConfiguration configuration = deserializer.Deserialize(File.ReadAllText(absolutePath)) + ?? throw new ConfigurationException("Configuration document is empty."); + Validate(configuration, Path.GetDirectoryName(absolutePath)!); + return configuration; + } + catch (ConfigurationException) + { + throw; + } + catch (Exception ex) + { + throw new ConfigurationException($"Could not parse YAML: {ex.Message}", ex); + } + } + + public static void Validate(AgentConfiguration value, string baseDirectory) + { + if (value.SchemaVersion != 1) Throw("schema_version must be 1."); + if (string.IsNullOrWhiteSpace(value.Application.Language)) Throw("application.language is required."); + if (value.Mcp.Transport is not ("stdio" or "http")) Throw("mcp.transport must be stdio or http."); + if (!IPAddress.TryParse(value.Mcp.Http.Host, out IPAddress? host) || !IPAddress.IsLoopback(host)) Throw("mcp.http.host must be 127.0.0.1 or ::1."); + if (value.Mcp.Http.Port is < 1 or > 65535) Throw("mcp.http.port is outside 1..65535."); + if (value.Mcp.Images.MaxCount < 0 || value.Mcp.Images.MaxBytesEach <= 0 || value.Mcp.Images.MaxTotalBytes <= 0) Throw("mcp.images limits must be positive."); + if (value.Mcp.Images.MaxTotalBytes < value.Mcp.Images.MaxBytesEach) Throw("mcp.images.max_total_bytes must be >= max_bytes_each."); + if (value.Agent.MaxIterations <= 0 || value.Agent.SameFailureLimit <= 0) Throw("agent iteration limits must be positive."); + if (!Uri.TryCreate(value.Ollama.Endpoint, UriKind.Absolute, out Uri? endpoint) || endpoint.Scheme is not ("http" or "https")) Throw("ollama.endpoint must be an absolute HTTP(S) URI."); + if (string.IsNullOrWhiteSpace(value.Ollama.Model)) Throw("ollama.model is required."); + 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."); + } + 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."); + + RequireFile(baseDirectory, value.Agent.UserSystemPrompt, "agent.user_system_prompt"); + RequireDirectory(baseDirectory, value.Localization.Directory, "localization.directory"); + RequireDirectory(baseDirectory, value.Skills.Directory, "skills.directory"); + } + + private static void RequireFile(string baseDirectory, string path, string key) + { + if (!File.Exists(Resolve(baseDirectory, path))) Throw($"{key} does not exist: {path}"); + } + + private static void RequireDirectory(string baseDirectory, string path, string key) + { + if (!Directory.Exists(Resolve(baseDirectory, path))) Throw($"{key} does not exist: {path}"); + } + + public static string Resolve(string baseDirectory, string path) => Path.GetFullPath(path, baseDirectory); + private static void Throw(string message) => throw new ConfigurationException(message); + [GeneratedRegex("^[0-9a-fA-F]{64}$", RegexOptions.CultureInvariant)] + private static partial Regex Sha256Regex(); +} diff --git a/src/Agent.App/Configuration/OllamaSettingsFactory.cs b/src/Agent.App/Configuration/OllamaSettingsFactory.cs new file mode 100644 index 0000000..cd71139 --- /dev/null +++ b/src/Agent.App/Configuration/OllamaSettingsFactory.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Agent.Core.Ollama; + +namespace Agent.App.Configuration; + +public static class OllamaSettingsFactory +{ + public static OllamaSettings Create(OllamaConfiguration configuration) + { + var options = configuration.ExtraOptions.ToDictionary( + static pair => pair.Key, + static pair => JsonSerializer.SerializeToElement(pair.Value, pair.Value?.GetType() ?? typeof(object)), + StringComparer.Ordinal); + Set(options, "num_ctx", configuration.Options.NumCtx); + Set(options, "num_keep", configuration.Options.NumKeep); + Set(options, "num_predict", configuration.Options.NumPredict); + Set(options, "temperature", configuration.Options.Temperature); + Set(options, "seed", configuration.Options.Seed); + Set(options, "top_k", configuration.Options.TopK); + Set(options, "top_p", configuration.Options.TopP); + Set(options, "min_p", configuration.Options.MinP); + Set(options, "typical_p", configuration.Options.TypicalP); + Set(options, "tfs_z", configuration.Options.TfsZ); + Set(options, "repeat_last_n", configuration.Options.RepeatLastN); + Set(options, "repeat_penalty", configuration.Options.RepeatPenalty); + Set(options, "presence_penalty", configuration.Options.PresencePenalty); + Set(options, "frequency_penalty", configuration.Options.FrequencyPenalty); + Set(options, "mirostat", configuration.Options.Mirostat); + Set(options, "mirostat_tau", configuration.Options.MirostatTau); + Set(options, "mirostat_eta", configuration.Options.MirostatEta); + if (configuration.Options.Stop.Count > 0) options["stop"] = JsonSerializer.SerializeToElement(configuration.Options.Stop); + return new OllamaSettings(new Uri(configuration.Endpoint, UriKind.Absolute), configuration.Model, configuration.Think, configuration.KeepAlive, options); + } + + private static void Set(Dictionary options, string key, T? value) where T : struct + { + if (value is not null) options[key] = JsonSerializer.SerializeToElement(value.Value); + } +} diff --git a/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs b/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs new file mode 100644 index 0000000..b7cbd26 --- /dev/null +++ b/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using Agent.Core.Ollama; +using Agent.Core.Runtime; +using Agent.Core.Tools; + +namespace Agent.App.Logging; + +public sealed class JsonLinesRuntimeObserver(string logDirectory) : IAgentRuntimeObserver +{ + private readonly object _sync = new(); + private readonly string _path = Path.Combine(logDirectory, "llm.jsonl"); + + public void LlmRequest(Guid taskId, int iteration, IReadOnlyList messages) => Write(new + { + timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.request", iteration, + messages = messages.Select(static message => new { message.Role, message.Content, image_count = message.Images?.Count ?? 0, message.ToolName }) + }); + + public void LlmResponse(Guid taskId, int iteration, OllamaResult result) => Write(new + { + timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.response", iteration, + result.Content, input_tokens = result.InputTokens, output_tokens = result.OutputTokens, + tool_calls = result.ToolCalls?.Select(static call => call.Function.Name).ToArray() ?? [] + }); + + public void ToolResult(Guid taskId, string toolName, ToolExecutionResult result) => Write(new + { + timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.tool_result", tool_name = toolName, + result.Success, result.ExternalEffect, result.Output, result.FailureFingerprint + }); + + private void Write(object value) + { + string line = JsonSerializer.Serialize(value); + lock (_sync) File.AppendAllText(_path, line + Environment.NewLine); + } +} diff --git a/src/Agent.App/Logging/RollingFileLog.cs b/src/Agent.App/Logging/RollingFileLog.cs new file mode 100644 index 0000000..801768d --- /dev/null +++ b/src/Agent.App/Logging/RollingFileLog.cs @@ -0,0 +1,55 @@ +using System.Globalization; + +namespace Agent.App.Logging; + +public sealed class RollingFileLog : IDisposable +{ + private readonly object _sync = new(); + private readonly string _path; + private readonly long _maxBytes; + private readonly int _retainedFiles; + private StreamWriter _writer; + + public RollingFileLog(string directory, long maxBytes, int retainedFiles) + { + Directory.CreateDirectory(directory); + _path = Path.Combine(directory, "application.log"); + _maxBytes = maxBytes; + _retainedFiles = retainedFiles; + _writer = Open(); + } + + public void Information(string message) => Write("INF", message); + public void Error(string message) => Write("ERR", message); + + private void Write(string level, string message) + { + lock (_sync) + { + if (_writer.BaseStream.Length >= _maxBytes) Rotate(); + string line = string.Create(CultureInfo.InvariantCulture, $"{DateTimeOffset.UtcNow:O} [{level}] {message}"); + Console.Error.WriteLine(line); + _writer.WriteLine(line); + _writer.Flush(); + } + } + + private StreamWriter Open() => new(new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read), new System.Text.UTF8Encoding(false)); + + private void Rotate() + { + _writer.Dispose(); + for (int index = _retainedFiles - 1; index >= 1; index--) + { + string source = $"{_path}.{index}"; + if (File.Exists(source)) File.Move(source, $"{_path}.{index + 1}", true); + } + if (File.Exists(_path)) File.Move(_path, $"{_path}.1", true); + _writer = Open(); + } + + public void Dispose() + { + lock (_sync) _writer.Dispose(); + } +} diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs new file mode 100644 index 0000000..3fae93f --- /dev/null +++ b/src/Agent.App/Program.cs @@ -0,0 +1,130 @@ +using Agent.App.Cli; +using Agent.App.Configuration; +using Agent.App.Logging; +using Agent.Core.Localization; +using Agent.Core.Prompts; +using Agent.Mcp; +using Agent.Core.Runtime; +using Agent.Windows.Diagnostics; +using Agent.Core.Tools; +using Agent.Windows.Recovery; +using Agent.Windows.Security; +using Agent.Windows.Tools; + +namespace Agent.App; + +internal static class Program +{ + public static async Task Main(string[] args) + { + try + { + CommandLine commandLine = CommandLine.Parse(args); + string configPath = commandLine.ConfigPath is null + ? Path.Combine(AppContext.BaseDirectory, "agent.yaml") + : Path.GetFullPath(commandLine.ConfigPath); + AgentConfiguration configuration = ConfigurationLoader.Load(configPath); + if (commandLine.Transport is not null) configuration.Mcp.Transport = commandLine.Transport; + ConfigurationLoader.Validate(configuration, Path.GetDirectoryName(configPath)!); + + string language = commandLine.Language ?? configuration.Application.Language ?? "en"; + string baseDirectory = Path.GetDirectoryName(configPath)!; + string localeDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Localization.Directory); + LocaleCatalog locale = LocaleCatalog.Load(localeDirectory, language, configuration.Localization.Fallback); + _ = FixedPrompt.Load(); + + string logDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Logging.Directory); + UserOnlyDirectory.Ensure(logDirectory); + using var log = new RollingFileLog(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles); + if (commandLine.Command == AppCommand.Check) + { + WindowsPlatformCheckResult platform = WindowsPlatformCheck.Run(); + Console.Out.WriteLine($"administrator={platform.IsAdministrator.ToString().ToLowerInvariant()}"); + Console.Out.WriteLine($"bfe_engine_open={platform.BfeEngineOpen.ToString().ToLowerInvariant()}"); + Console.Out.WriteLine($"wfp_write_transaction={platform.WfpWriteTransaction.ToString().ToLowerInvariant()}"); + Console.Out.WriteLine($"wfp_filter_add_delete={platform.WfpFilterAddDelete.ToString().ToLowerInvariant()}"); + if (!platform.Success) + { + string detail = $"{locale.Get("check.wfp_admin_required")} error_code={platform.ErrorCode}; operation={platform.ErrorOperation}"; + string failure = locale.Format("check.failure", new Dictionary { ["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; + } + + log.Information(locale.Get("app.starting")); + InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, baseDirectory, logDirectory); + var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory); + if (configuration.Mcp.Transport == "stdio") + { + string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt)); + string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory); + string[] skills = Directory.EnumerateFiles(skillDirectory, "*.md", SearchOption.TopDirectoryOnly).Select(File.ReadAllText).ToArray(); + var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported")); + var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes); + await McpHost.RunStdioAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits, CancellationToken.None, toolDispatcher, runtimeObserver).ConfigureAwait(false); + return 0; + } + if (configuration.Mcp.Transport == "http") + { + string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt)); + string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory); + string[] skills = Directory.EnumerateFiles(skillDirectory, "*.md", SearchOption.TopDirectoryOnly).Select(File.ReadAllText).ToArray(); + var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported")); + var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes); + await McpHost.RunHttpAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits, + configuration.Mcp.Http.Host, configuration.Mcp.Http.Port, CancellationToken.None, toolDispatcher, runtimeObserver).ConfigureAwait(false); + return 0; + } + throw new ConfigurationException("Unsupported MCP transport."); + } + catch (Exception ex) when (ex is CommandLineException or ConfigurationException or LocaleException or IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"agent-mcp: {ex.Message}"); + return 2; + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + Console.Error.WriteLine($"agent-mcp: [APP-UNHANDLED] {ex.Message}"); + return 2; + } + } + + private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, string baseDirectory, string logDirectory) + { + string stateDirectory = Path.Combine(baseDirectory, "state"); + string journalDirectory = Path.Combine(stateDirectory, "journals"); + string backupDirectory = Path.Combine(stateDirectory, "backups"); + Directory.CreateDirectory(journalDirectory); + Directory.CreateDirectory(backupDirectory); + var audit = new JsonLinesSecurityAuditSink(Path.Combine(logDirectory, "audit.jsonl")); + RecoveryResult recovery = new RecoveryManager(journalDirectory, audit).RecoverAll(); + if (!recovery.IsClean) throw new InvalidOperationException("Recovery did not reach a clean state: " + string.Join(" | ", recovery.Errors)); + var fileEntries = new List(); + 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)item.Ports)).ToArray(), + journalDirectory, backupDirectory, checked((long)configuration.FileEdit.MemoryBackupLimitMb * 1024 * 1024), + configuration.Limits.WholeReadBytes, configuration.Limits.SearchMaxResults, configuration.Limits.TailMaxLines); + var backend = new WindowsToolBackend(settings, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks)); + var allowed = InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal); + return new InternalToolDispatcher(new DenyByDefaultPolicy(allowed), backend); + + void Add(IEnumerable paths, FileCapability capability) + { + foreach (string path in paths) fileEntries.Add(new FilePolicyEntry(Path.IsPathFullyQualified(path) ? path : ConfigurationLoader.Resolve(baseDirectory, path), capability)); + } + } +} diff --git a/src/Agent.Core/Agent.Core.csproj b/src/Agent.Core/Agent.Core.csproj new file mode 100644 index 0000000..9816d8a --- /dev/null +++ b/src/Agent.Core/Agent.Core.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Agent.Core/AssemblyMarker.cs b/src/Agent.Core/AssemblyMarker.cs new file mode 100644 index 0000000..2efa480 --- /dev/null +++ b/src/Agent.Core/AssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace Agent.Core; + +public static class AssemblyMarker; diff --git a/src/Agent.Core/Localization/LanguageContext.cs b/src/Agent.Core/Localization/LanguageContext.cs new file mode 100644 index 0000000..f8878b5 --- /dev/null +++ b/src/Agent.Core/Localization/LanguageContext.cs @@ -0,0 +1,3 @@ +namespace Agent.Core.Localization; + +public sealed record LanguageContext(string Locale); diff --git a/src/Agent.Core/Localization/LocaleCatalog.cs b/src/Agent.Core/Localization/LocaleCatalog.cs new file mode 100644 index 0000000..3c49283 --- /dev/null +++ b/src/Agent.Core/Localization/LocaleCatalog.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Agent.Core.Localization; + +public sealed partial class LocaleCatalog +{ + private readonly IReadOnlyDictionary> _locales; + private readonly IReadOnlyDictionary _fallback; + + private LocaleCatalog(string selectedLocale, IReadOnlyDictionary> locales, IReadOnlyDictionary fallback) + { + SelectedLocale = selectedLocale; + _locales = locales; + _fallback = fallback; + } + + public string SelectedLocale { get; } + public IReadOnlyCollection Keys => _fallback.Keys.ToArray(); + + public static LocaleCatalog Load(string directory, string selectedLocale, string fallbackLocale = "en") + { + if (!Directory.Exists(directory)) throw new LocaleException($"Locale directory does not exist: {directory}"); + + var locales = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (string file in Directory.EnumerateFiles(directory, "*.json", SearchOption.TopDirectoryOnly)) + { + string locale = Path.GetFileNameWithoutExtension(file); + locales.Add(locale, ReadFlatObject(file)); + } + + if (!locales.TryGetValue(fallbackLocale, out IReadOnlyDictionary? fallback)) + throw new LocaleException($"Fallback locale '{fallbackLocale}' is missing."); + + HashSet baselineKeys = fallback.Keys.ToHashSet(StringComparer.Ordinal); + Dictionary> baselinePlaceholders = fallback.ToDictionary(static p => p.Key, static p => Placeholders(p.Value), StringComparer.Ordinal); + foreach ((string locale, IReadOnlyDictionary values) in locales) + { + string? unknown = values.Keys.FirstOrDefault(key => !baselineKeys.Contains(key)); + if (unknown is not null) throw new LocaleException($"Locale '{locale}' contains unknown key '{unknown}'."); + foreach ((string key, string message) in values) + { + if (!baselinePlaceholders[key].SetEquals(Placeholders(message))) + throw new LocaleException($"Locale '{locale}' has mismatched placeholders for key '{key}'."); + } + } + + if (!locales.ContainsKey(selectedLocale)) throw new LocaleException($"Selected locale '{selectedLocale}' is missing."); + return new LocaleCatalog(selectedLocale, locales, fallback); + } + + public string Get(string key) + { + if (_locales[SelectedLocale].TryGetValue(key, out string? selected)) return selected; + return _fallback.TryGetValue(key, out string? fallback) ? fallback : key; + } + + public string Format(string key, IReadOnlyDictionary values) + { + string template = Get(key); + return PlaceholderRegex().Replace(template, match => + { + string name = match.Groups[1].Value; + if (!values.TryGetValue(name, out object? value)) throw new LocaleException($"Missing placeholder value '{name}' for key '{key}'."); + return Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + }); + } + + private static Dictionary ReadFlatObject(string file) + { + try + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(file)); + if (document.RootElement.ValueKind != JsonValueKind.Object) throw new LocaleException($"Locale file must contain a JSON object: {file}"); + var result = new Dictionary(StringComparer.Ordinal); + foreach (JsonProperty property in document.RootElement.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.String) throw new LocaleException($"Locale value '{property.Name}' must be a string."); + if (!result.TryAdd(property.Name, property.Value.GetString()!)) throw new LocaleException($"Duplicate locale key '{property.Name}'."); + } + return result; + } + catch (LocaleException) + { + throw; + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + throw new LocaleException($"Could not load locale file '{file}': {ex.Message}", ex); + } + } + + private static HashSet Placeholders(string text) => PlaceholderRegex().Matches(text).Select(static m => m.Groups[1].Value).ToHashSet(StringComparer.Ordinal); + + [GeneratedRegex("\\{([A-Za-z][A-Za-z0-9_]*)\\}", RegexOptions.CultureInvariant)] + private static partial Regex PlaceholderRegex(); +} diff --git a/src/Agent.Core/Localization/LocaleException.cs b/src/Agent.Core/Localization/LocaleException.cs new file mode 100644 index 0000000..788a45c --- /dev/null +++ b/src/Agent.Core/Localization/LocaleException.cs @@ -0,0 +1,4 @@ +namespace Agent.Core.Localization; + +public sealed class LocaleException(string message, Exception? innerException = null) + : Exception(message, innerException); diff --git a/src/Agent.Core/Ollama/IOllamaClient.cs b/src/Agent.Core/Ollama/IOllamaClient.cs new file mode 100644 index 0000000..a03a353 --- /dev/null +++ b/src/Agent.Core/Ollama/IOllamaClient.cs @@ -0,0 +1,11 @@ +namespace Agent.Core.Ollama; + +using Agent.Core.Tools; + +public interface IOllamaClient +{ + Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken); + Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) => + ChatAsync(messages, temperatureOverride, cancellationToken); + Task SupportsVisionAsync(CancellationToken cancellationToken) => Task.FromResult(null); +} diff --git a/src/Agent.Core/Ollama/OllamaClient.cs b/src/Agent.Core/Ollama/OllamaClient.cs new file mode 100644 index 0000000..bfd2210 --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaClient.cs @@ -0,0 +1,92 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Agent.Core.Tools; + +namespace Agent.Core.Ollama; + +public sealed class OllamaClient : IOllamaClient +{ + private static readonly TimeSpan[] RetryDelays = [TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1)]; + private readonly HttpClient _httpClient; + private readonly OllamaSettings _settings; + private readonly Func _delay; + private bool? _visionCapability; + + public OllamaClient(HttpClient httpClient, OllamaSettings settings, Func? delay = null) + { + _httpClient = httpClient; + _httpClient.BaseAddress = settings.Endpoint; + _settings = settings; + _delay = delay ?? Task.Delay; + } + + public async Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) + => await ChatCoreAsync(messages, temperatureOverride, null, cancellationToken).ConfigureAwait(false); + + public async Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) + => await ChatCoreAsync(messages, temperatureOverride, tools, cancellationToken).ConfigureAwait(false); + + public async Task SupportsVisionAsync(CancellationToken cancellationToken) + { + if (_visionCapability is not null) return _visionCapability; + try + { + using HttpResponseMessage response = await _httpClient.PostAsJsonAsync("api/show", new { model = _settings.Model }, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) return null; + using JsonDocument document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), cancellationToken: cancellationToken).ConfigureAwait(false); + if (!document.RootElement.TryGetProperty("capabilities", out JsonElement capabilities) || capabilities.ValueKind != JsonValueKind.Array) return null; + _visionCapability = capabilities.EnumerateArray().Any(static item => item.GetString()?.Equals("vision", StringComparison.OrdinalIgnoreCase) == true); + return _visionCapability; + } + catch (HttpRequestException) { return null; } + } + + private async Task ChatCoreAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList? tools, CancellationToken cancellationToken) + { + Dictionary options = _settings.Options.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + if (temperatureOverride is not null) options["temperature"] = JsonSerializer.SerializeToElement(temperatureOverride.Value); + var request = new OllamaChatRequest + { + Model = _settings.Model, + Messages = messages, + Stream = false, + Think = _settings.Think, + KeepAlive = _settings.KeepAlive, + Options = options, + Tools = tools?.Select(static tool => new OllamaToolDefinition("function", new OllamaFunctionDefinition(tool.Name, tool.Description, tool.Parameters))).ToArray() + }; + + for (int attempt = 0; ; attempt++) + { + try + { + using HttpResponseMessage response = await _httpClient.PostAsJsonAsync("api/chat", request, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + bool transient = IsTransient(response.StatusCode); + if (transient && attempt < RetryDelays.Length) + { + await _delay(RetryDelays[attempt], cancellationToken).ConfigureAwait(false); + continue; + } + throw new OllamaException($"Ollama returned HTTP {(int)response.StatusCode}: {body}", transient); + } + + OllamaChatResponse result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken).ConfigureAwait(false) + ?? throw new OllamaException("Ollama returned an empty response.", false); + if (!string.IsNullOrWhiteSpace(result.Error)) throw new OllamaException(result.Error, false); + if (result.Message is null) throw new OllamaException("Ollama response has no message.", false); + return new OllamaResult(result.Message.Content, result.PromptEvalCount, result.EvalCount, result.Message.ToolCalls ?? []); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + if (attempt >= RetryDelays.Length) throw new OllamaException("Ollama is unavailable after three retries.", true, ex); + await _delay(RetryDelays[attempt], cancellationToken).ConfigureAwait(false); + } + } + } + + private static bool IsTransient(HttpStatusCode statusCode) => statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || (int)statusCode >= 500; +} diff --git a/src/Agent.Core/Ollama/OllamaException.cs b/src/Agent.Core/Ollama/OllamaException.cs new file mode 100644 index 0000000..6b0c158 --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaException.cs @@ -0,0 +1,7 @@ +namespace Agent.Core.Ollama; + +public sealed class OllamaException(string message, bool transient, Exception? innerException = null) + : Exception(message, innerException) +{ + public bool IsTransient { get; } = transient; +} diff --git a/src/Agent.Core/Ollama/OllamaModels.cs b/src/Agent.Core/Ollama/OllamaModels.cs new file mode 100644 index 0000000..823a178 --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaModels.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; +using System.Text.Json; +using Agent.Core.Tools; + +namespace Agent.Core.Ollama; + +public sealed record OllamaMessage( + [property: JsonPropertyName("role")] string Role, + [property: JsonPropertyName("content")] string Content, + [property: JsonPropertyName("images"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? Images = null, + [property: JsonPropertyName("tool_name"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ToolName = null, + [property: JsonPropertyName("tool_calls"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? ToolCalls = null); + +public sealed record OllamaToolCall([property: JsonPropertyName("function")] OllamaFunctionCall Function); +public sealed record OllamaFunctionCall( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("arguments")] JsonElement Arguments); + +public sealed record OllamaToolDefinition( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("function")] OllamaFunctionDefinition Function); +public sealed record OllamaFunctionDefinition( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("parameters")] JsonElement Parameters); + +public sealed class OllamaChatRequest +{ + [JsonPropertyName("model")] public required string Model { get; init; } + [JsonPropertyName("messages")] public required IReadOnlyList Messages { get; init; } + [JsonPropertyName("stream")] public bool Stream { get; init; } + [JsonPropertyName("think")] public bool Think { get; init; } + [JsonPropertyName("keep_alive")] public required string KeepAlive { get; init; } + [JsonPropertyName("options")] public required IReadOnlyDictionary Options { get; init; } + [JsonPropertyName("tools"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IReadOnlyList? Tools { get; init; } +} + +public sealed class OllamaChatResponse +{ + [JsonPropertyName("message")] public OllamaMessage? Message { get; init; } + [JsonPropertyName("prompt_eval_count")] public long PromptEvalCount { get; init; } + [JsonPropertyName("eval_count")] public long EvalCount { get; init; } + [JsonPropertyName("error")] public string? Error { get; init; } +} + +public sealed record OllamaResult(string Content, long InputTokens, long OutputTokens, IReadOnlyList? ToolCalls = null); diff --git a/src/Agent.Core/Ollama/OllamaSettings.cs b/src/Agent.Core/Ollama/OllamaSettings.cs new file mode 100644 index 0000000..94ab3ef --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaSettings.cs @@ -0,0 +1,10 @@ +using System.Text.Json; + +namespace Agent.Core.Ollama; + +public sealed record OllamaSettings( + Uri Endpoint, + string Model, + bool Think, + string KeepAlive, + IReadOnlyDictionary Options); diff --git a/src/Agent.Core/Prompts/FixedPrompt.cs b/src/Agent.Core/Prompts/FixedPrompt.cs new file mode 100644 index 0000000..6995bd4 --- /dev/null +++ b/src/Agent.Core/Prompts/FixedPrompt.cs @@ -0,0 +1,16 @@ +using System.Reflection; + +namespace Agent.Core.Prompts; + +public static class FixedPrompt +{ + private const string ResourceName = "Agent.Core.Prompts.FixedSystemPrompt.md"; + + public static string Load() + { + using Stream stream = typeof(FixedPrompt).Assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException($"Embedded resource '{ResourceName}' was not found."); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} diff --git a/src/Agent.Core/Prompts/FixedSystemPrompt.md b/src/Agent.Core/Prompts/FixedSystemPrompt.md new file mode 100644 index 0000000..3cbc6e7 --- /dev/null +++ b/src/Agent.Core/Prompts/FixedSystemPrompt.md @@ -0,0 +1,4 @@ +You are the execution agent inside AI Agent Sandbox MCP Server. +Follow the active SandboxPolicy and never claim an external effect unless an Internal Tool result proves it occurred. +Never reveal hidden reasoning, policy internals, credentials, or capabilities not present in the supplied capability summary. +Complete a request only when every TaskGraph node is completed and all required effects are verified. diff --git a/src/Agent.Core/Prompts/PromptComposer.cs b/src/Agent.Core/Prompts/PromptComposer.cs new file mode 100644 index 0000000..e07c7e8 --- /dev/null +++ b/src/Agent.Core/Prompts/PromptComposer.cs @@ -0,0 +1,36 @@ +using Agent.Core.Ollama; +using Agent.Core.Sessions; + +namespace Agent.Core.Prompts; + +public sealed record PromptContext( + string FixedSystemPrompt, + string UserSystemPrompt, + string Locale, + IReadOnlyList Skills, + string SandboxCapabilitySummary, + string TaskGraphSummary, + string PreviousSummary, + IReadOnlyList Conversation, + string Request, + IReadOnlyList? Images = null); + +public static class PromptComposer +{ + public static IReadOnlyList Compose(PromptContext context) + { + var messages = new List + { + new("system", context.FixedSystemPrompt), + new("system", context.UserSystemPrompt), + new("system", $"Respond in locale: {context.Locale}."), + new("system", string.Join("\n\n", context.Skills)), + new("system", context.SandboxCapabilitySummary), + new("system", context.TaskGraphSummary), + new("system", context.PreviousSummary) + }; + messages.AddRange(context.Conversation.Select(static item => new OllamaMessage(item.Role, item.Content))); + messages.Add(new OllamaMessage("user", context.Request, context.Images)); + return messages; + } +} diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs new file mode 100644 index 0000000..c2fa568 --- /dev/null +++ b/src/Agent.Core/Runtime/AgentRuntime.cs @@ -0,0 +1,165 @@ +using Agent.Core.Ollama; +using Agent.Core.Prompts; +using Agent.Core.Sessions; +using Agent.Core.Tasks; +using Agent.Core.Tools; + +namespace Agent.Core.Runtime; + +public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null) +{ + private readonly InternalToolDispatcher _tools = toolDispatcher ?? new( + new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), + new FakeInternalToolBackend()); + private readonly IAgentRuntimeObserver _observer = observer ?? NullAgentRuntimeObserver.Instance; + + public async Task ExecuteAsync(Guid taskId, CancellationToken cancellationToken) + { + if (!registry.TryGet(taskId, null, out TaskSnapshot? task) || task is null) return; + if (!registry.Sessions.TryGet(task.SessionId, out AgentSession? session) || session is null) + { + registry.Transition(taskId, AgentTaskState.Failed, result: "Session not found."); + return; + } + + try + { + await _tools.BeginTaskAsync(taskId, cancellationToken).ConfigureAwait(false); + if (task.Images.Count > 0 && await ollama.SupportsVisionAsync(cancellationToken).ConfigureAwait(false) is false) + throw new InvalidOperationException(settings.VisionUnsupportedMessage); + var messages = PromptComposer.Compose(new PromptContext( + FixedPrompt.Load(), + settings.UserSystemPrompt, + settings.Locale, + settings.Skills, + settings.SandboxCapabilitySummary, + "Current task: " + task.Request, + string.Empty, + session.Conversation, + task.Request, + task.Images)).ToList(); + var guard = new ToolUseGuard(settings.SameFailureLimit); + bool requiresExternalEffect = RequiresExternalEffect(task.Request); + bool externalEffectEvidence = false; + var unresolvedToolFailures = new Dictionary(StringComparer.Ordinal); + IReadOnlyDictionary requiredToolCalls = RequiredToolCalls(task.Request); + var successfulToolCalls = new Dictionary(StringComparer.Ordinal); + string? finalContent = null; + for (int iteration = 0; iteration < settings.MaxIterations; iteration++) + { + _observer.LlmRequest(taskId, iteration, messages); + OllamaResult result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false); + _observer.LlmResponse(taskId, iteration, result); + registry.AddUsage(taskId, result.InputTokens, result.OutputTokens); + IReadOnlyList calls = result.ToolCalls ?? []; + if (calls.Count > 0) + { + messages.Add(new OllamaMessage("assistant", result.Content, ToolCalls: calls)); + string? failureFingerprint = null; + foreach (OllamaToolCall call in calls) + { + ToolExecutionResult toolResult; + try + { + toolResult = await _tools.DispatchAsync(taskId, call.Function.Name, call.Function.Arguments, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is ToolValidationException or ArgumentException or UnauthorizedAccessException) + { + toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}"); + } + externalEffectEvidence |= toolResult.Success && toolResult.ExternalEffect; + if (toolResult.Success) unresolvedToolFailures.Remove(call.Function.Name); + else unresolvedToolFailures[call.Function.Name] = toolResult.FailureFingerprint ?? toolResult.Output; + if (toolResult.Success) successfulToolCalls[call.Function.Name] = successfulToolCalls.GetValueOrDefault(call.Function.Name) + 1; + _observer.ToolResult(taskId, call.Function.Name, toolResult); + failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint; + messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name)); + } + ToolUseGuardResult callGuard = guard.Evaluate(requiresExternalEffect, true, externalEffectEvidence, failureFingerprint); + if (callGuard.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(callGuard.Observation); + continue; + } + + if (unresolvedToolFailures.Count > 0) + { + messages.Add(new OllamaMessage("system", "Completion is not allowed while tool failures remain unresolved. Retry or correct: " + + string.Join("; ", unresolvedToolFailures.Select(static item => $"{item.Key}={item.Value}")))); + continue; + } + string[] missingCalls = requiredToolCalls + .Where(requirement => successfulToolCalls.GetValueOrDefault(requirement.Key) < requirement.Value) + .Select(requirement => $"{requirement.Key}: {successfulToolCalls.GetValueOrDefault(requirement.Key)}/{requirement.Value}") + .ToArray(); + if (missingCalls.Length > 0) + { + messages.Add(new OllamaMessage("system", "Completion is not allowed because the request explicitly requires successful tool calls that are still missing: " + string.Join("; ", missingCalls))); + continue; + } + ToolUseGuardResult completion = guard.Evaluate(requiresExternalEffect, false, externalEffectEvidence); + if (completion.Decision == ToolUseGuardDecision.Retry) + { + messages.Add(new OllamaMessage("system", completion.Observation)); + continue; + } + if (completion.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(completion.Observation); + finalContent = result.Content; + break; + } + if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the iteration limit of {settings.MaxIterations}."); + string? cleanupFailure = await EndTaskSafelyAsync(taskId, true, CancellationToken.None).ConfigureAwait(false); + if (cleanupFailure is not null) throw new InvalidOperationException(cleanupFailure); + session.Append(new ConversationMessage("user", task.Request)); + session.Append(new ConversationMessage("assistant", finalContent)); + registry.Transition(taskId, AgentTaskState.Completed, result: finalContent); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false); + string result = cleanupFailure is null ? "Cancelled." : $"Cancelled. {cleanupFailure}"; + registry.Transition(taskId, AgentTaskState.Cancelled, result: result); + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false); + string result = cleanupFailure is null ? ex.Message : $"{ex.Message} {cleanupFailure}"; + registry.Transition(taskId, AgentTaskState.Failed, result: result); + } + } + + private async Task EndTaskSafelyAsync(Guid taskId, bool success, CancellationToken cancellationToken) + { + try + { + await _tools.EndTaskAsync(taskId, success, cancellationToken).ConfigureAwait(false); + return null; + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + return $"Task resource cleanup failed: {ex.Message}"; + } + } + + private static bool RequiresExternalEffect(string request) + { + string normalized = request.ToLowerInvariant(); + string[] indicators = ["edit", "modify", "change", "fix", "write", "delete", "create", "build", "test", "run", "修正", "変更", "編集", "作成", "削除", "ビルド", "テスト", "実行"]; + return indicators.Any(normalized.Contains); + } + + private static IReadOnlyDictionary RequiredToolCalls(string request) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (InternalToolDefinition tool in InternalToolCatalog.All) + { + int count = 0; + int offset = 0; + while ((offset = request.IndexOf(tool.Name, offset, StringComparison.OrdinalIgnoreCase)) >= 0) + { + count++; + offset += tool.Name.Length; + } + if (count > 0) result[tool.Name] = count; + } + return result; + } +} diff --git a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs new file mode 100644 index 0000000..9bc04b9 --- /dev/null +++ b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs @@ -0,0 +1,10 @@ +namespace Agent.Core.Runtime; + +public sealed record AgentRuntimeSettings( + string UserSystemPrompt, + string Locale, + IReadOnlyList Skills, + string SandboxCapabilitySummary, + int MaxIterations = 128, + int SameFailureLimit = 5, + string VisionUnsupportedMessage = "The selected model does not support image input."); diff --git a/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs new file mode 100644 index 0000000..e4e26e3 --- /dev/null +++ b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs @@ -0,0 +1,19 @@ +using Agent.Core.Ollama; +using Agent.Core.Tools; + +namespace Agent.Core.Runtime; + +public interface IAgentRuntimeObserver +{ + void LlmRequest(Guid taskId, int iteration, IReadOnlyList messages); + void LlmResponse(Guid taskId, int iteration, OllamaResult result); + void ToolResult(Guid taskId, string toolName, ToolExecutionResult result); +} + +public sealed class NullAgentRuntimeObserver : IAgentRuntimeObserver +{ + public static NullAgentRuntimeObserver Instance { get; } = new(); + public void LlmRequest(Guid taskId, int iteration, IReadOnlyList messages) { } + public void LlmResponse(Guid taskId, int iteration, OllamaResult result) { } + public void ToolResult(Guid taskId, string toolName, ToolExecutionResult result) { } +} diff --git a/src/Agent.Core/Sessions/AgentSession.cs b/src/Agent.Core/Sessions/AgentSession.cs new file mode 100644 index 0000000..d561c8d --- /dev/null +++ b/src/Agent.Core/Sessions/AgentSession.cs @@ -0,0 +1,26 @@ +namespace Agent.Core.Sessions; + +public sealed class AgentSession(Guid sessionId) +{ + private readonly object _sync = new(); + private readonly List _conversation = []; + + public Guid SessionId { get; } = sessionId; + public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastAccessAt { get; private set; } = DateTimeOffset.UtcNow; + + public IReadOnlyList Conversation + { + get { lock (_sync) return _conversation.ToArray(); } + } + + public void Append(ConversationMessage message) + { + ArgumentNullException.ThrowIfNull(message); + lock (_sync) + { + _conversation.Add(message); + LastAccessAt = DateTimeOffset.UtcNow; + } + } +} diff --git a/src/Agent.Core/Sessions/ConversationMessage.cs b/src/Agent.Core/Sessions/ConversationMessage.cs new file mode 100644 index 0000000..9d48747 --- /dev/null +++ b/src/Agent.Core/Sessions/ConversationMessage.cs @@ -0,0 +1,3 @@ +namespace Agent.Core.Sessions; + +public sealed record ConversationMessage(string Role, string Content); diff --git a/src/Agent.Core/Sessions/SessionStore.cs b/src/Agent.Core/Sessions/SessionStore.cs new file mode 100644 index 0000000..59ea02c --- /dev/null +++ b/src/Agent.Core/Sessions/SessionStore.cs @@ -0,0 +1,19 @@ +using System.Collections.Concurrent; + +namespace Agent.Core.Sessions; + +public sealed class SessionStore +{ + private readonly ConcurrentDictionary _sessions = new(); + + public AgentSession Create() + { + while (true) + { + var session = new AgentSession(Guid.NewGuid()); + if (_sessions.TryAdd(session.SessionId, session)) return session; + } + } + + public bool TryGet(Guid sessionId, out AgentSession? session) => _sessions.TryGetValue(sessionId, out session); +} diff --git a/src/Agent.Core/Tasks/AgentTaskState.cs b/src/Agent.Core/Tasks/AgentTaskState.cs new file mode 100644 index 0000000..927e8d7 --- /dev/null +++ b/src/Agent.Core/Tasks/AgentTaskState.cs @@ -0,0 +1,11 @@ +namespace Agent.Core.Tasks; + +public enum AgentTaskState +{ + Queued, + Planning, + Running, + Completed, + Failed, + Cancelled +} diff --git a/src/Agent.Core/Tasks/TaskRegistry.cs b/src/Agent.Core/Tasks/TaskRegistry.cs new file mode 100644 index 0000000..b61e525 --- /dev/null +++ b/src/Agent.Core/Tasks/TaskRegistry.cs @@ -0,0 +1,115 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using Agent.Core.Sessions; + +namespace Agent.Core.Tasks; + +public sealed class TaskRegistry +{ + private readonly ConcurrentDictionary _tasks = new(); + private readonly SessionStore _sessions; + private readonly Channel _queue = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + + public TaskRegistry(SessionStore? sessions = null) => _sessions = sessions ?? new SessionStore(); + + public SessionStore Sessions => _sessions; + + public TaskSnapshot Enqueue(string request, Guid? requestedSessionId, double? temperature, IReadOnlyList? images = null) + { + if (string.IsNullOrWhiteSpace(request)) throw new ArgumentException("Request cannot be empty.", nameof(request)); + if (temperature is < 0 or > 2) throw new ArgumentOutOfRangeException(nameof(temperature)); + Guid sessionId; + if (requestedSessionId is { } existing) + { + if (!_sessions.TryGet(existing, out _)) throw new KeyNotFoundException($"Session '{existing}' was not found."); + sessionId = existing; + } + else + { + sessionId = _sessions.Create().SessionId; + } + + var task = new MutableTask(Guid.NewGuid(), sessionId, request, temperature, images ?? []); + if (!_tasks.TryAdd(task.TaskId, task)) throw new InvalidOperationException("Could not register task."); + if (!_queue.Writer.TryWrite(task.TaskId)) + { + _tasks.TryRemove(task.TaskId, out _); + throw new InvalidOperationException("Task queue is not accepting work."); + } + return task.Snapshot(); + } + + public bool TryGet(Guid taskId, Guid? sessionId, out TaskSnapshot? snapshot) + { + if (_tasks.TryGetValue(taskId, out MutableTask? task) && (sessionId is null || task.SessionId == sessionId)) + { + snapshot = task.Snapshot(); + return true; + } + snapshot = null; + return false; + } + + public IAsyncEnumerable ReadQueuedAsync(CancellationToken cancellationToken) => _queue.Reader.ReadAllAsync(cancellationToken); + + public bool Transition(Guid taskId, AgentTaskState state, string? currentTask = null, string? result = null) + { + return _tasks.TryGetValue(taskId, out MutableTask? task) && task.Transition(state, currentTask, result); + } + + public bool AddUsage(Guid taskId, long inputTokens, long outputTokens) => + _tasks.TryGetValue(taskId, out MutableTask? task) && task.AddUsage(inputTokens, outputTokens); + + private sealed class MutableTask(Guid taskId, Guid sessionId, string request, double? temperature, IReadOnlyList images) + { + private readonly object _sync = new(); + private AgentTaskState _state = AgentTaskState.Queued; + private string? _currentTask; + private string? _result; + private long _inputTokens; + private long _outputTokens; + + public Guid TaskId { get; } = taskId; + public Guid SessionId { get; } = sessionId; + + public TaskSnapshot Snapshot() + { + lock (_sync) + { + int completed = _state == AgentTaskState.Completed ? 1 : 0; + return new(TaskId, SessionId, _state, request, temperature, _currentTask, _result, completed, 1, _inputTokens, _outputTokens, 0, images); + } + } + + public bool AddUsage(long inputTokens, long outputTokens) + { + if (inputTokens < 0 || outputTokens < 0) return false; + lock (_sync) + { + _inputTokens += inputTokens; + _outputTokens += outputTokens; + return true; + } + } + + public bool Transition(AgentTaskState next, string? currentTask, string? result) + { + lock (_sync) + { + if (!Allowed(_state, next)) return false; + _state = next; + _currentTask = currentTask; + _result = result; + return true; + } + } + + private static bool Allowed(AgentTaskState current, AgentTaskState next) => current switch + { + AgentTaskState.Queued => next is AgentTaskState.Planning or AgentTaskState.Failed or AgentTaskState.Cancelled, + AgentTaskState.Planning => next is AgentTaskState.Running or AgentTaskState.Failed or AgentTaskState.Cancelled, + AgentTaskState.Running => next is AgentTaskState.Planning or AgentTaskState.Completed or AgentTaskState.Failed or AgentTaskState.Cancelled, + _ => false + }; + } +} diff --git a/src/Agent.Core/Tasks/TaskSnapshot.cs b/src/Agent.Core/Tasks/TaskSnapshot.cs new file mode 100644 index 0000000..0a15de3 --- /dev/null +++ b/src/Agent.Core/Tasks/TaskSnapshot.cs @@ -0,0 +1,16 @@ +namespace Agent.Core.Tasks; + +public sealed record TaskSnapshot( + Guid TaskId, + Guid SessionId, + AgentTaskState State, + string Request, + double? Temperature, + string? CurrentTask, + string? Result, + int CompletedTasks, + int TotalTasks, + long InputTokens, + long OutputTokens, + double CriticalPathTokens, + IReadOnlyList Images); diff --git a/src/Agent.Core/Tools/InternalToolCatalog.cs b/src/Agent.Core/Tools/InternalToolCatalog.cs new file mode 100644 index 0000000..1563c16 --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolCatalog.cs @@ -0,0 +1,32 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public static class InternalToolCatalog +{ + private static readonly IReadOnlyDictionary Definitions = CreateDefinitions(); + + public static IReadOnlyList All { get; } = Definitions.Values.ToArray(); + public static InternalToolDefinition Get(string name) => Definitions.TryGetValue(name, out InternalToolDefinition? definition) + ? definition + : throw new ToolValidationException($"Unknown internal tool '{name}'."); + + private static IReadOnlyDictionary CreateDefinitions() + { + InternalToolDefinition[] definitions = + [ + Define("file_search", "Search files under an approved root.", """{"type":"object","additionalProperties":false,"required":["root","mode","query"],"properties":{"root":{"type":"string"},"mode":{"type":"string","enum":["glob","name","text","regex","tail"]},"query":{"type":"string"},"max_results":{"type":"integer","minimum":1,"maximum":1000},"tail_lines":{"type":"integer","minimum":1,"maximum":10000}}}"""), + Define("file_read", "Read an approved file.", """{"type":"object","additionalProperties":false,"required":["path","mode"],"properties":{"path":{"type":"string"},"mode":{"type":"string","enum":["whole","range","metadata"]},"start":{"type":"integer","minimum":0},"length":{"type":"integer","minimum":0}}}"""), + Define("file_edit", "Edit an approved file transactionally.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["overwrite","patch","atomic_replace","delete"]},"content":{"type":["string","null"]},"patch":{"type":["string","null"]},"expected_sha256":{"type":["string","null"]}}}"""), + Define("process_run", "Run one approved executable with separated arguments.", """{"type":"object","additionalProperties":false,"required":["executable","args","cwd"],"properties":{"executable":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":"string"},"timeout_seconds":{"type":"integer","minimum":1},"stdin":{"type":["string","null"]}}}"""), + Define("task_update", "Update the deterministic task graph.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","set_state","set_estimate"]},"task_id":{"type":["string","null"]},"description":{"type":["string","null"]},"depends_on":{"type":"array","items":{"type":"string"}},"budget_class":{"type":["string","null"],"enum":["1K","2K","4K","8K","16K","32K","64K","128K",null]},"optimistic_tokens":{"type":"integer","minimum":0},"most_likely_tokens":{"type":"integer","minimum":0},"pessimistic_tokens":{"type":"integer","minimum":0},"state":{"type":["string","null"],"enum":["queued","planning","running","completed","failed","cancelled",null]}}}""") + ]; + return definitions.ToDictionary(static definition => definition.Name, StringComparer.Ordinal); + } + + private static InternalToolDefinition Define(string name, string description, string schema) + { + using JsonDocument document = JsonDocument.Parse(schema); + return new(name, description, document.RootElement.Clone()); + } +} diff --git a/src/Agent.Core/Tools/InternalToolDefinition.cs b/src/Agent.Core/Tools/InternalToolDefinition.cs new file mode 100644 index 0000000..8f06764 --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolDefinition.cs @@ -0,0 +1,5 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public sealed record InternalToolDefinition(string Name, string Description, JsonElement Parameters); diff --git a/src/Agent.Core/Tools/InternalToolDispatcher.cs b/src/Agent.Core/Tools/InternalToolDispatcher.cs new file mode 100644 index 0000000..7302c1f --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolDispatcher.cs @@ -0,0 +1,29 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public sealed class InternalToolDispatcher(IToolPolicyEngine policy, IInternalToolBackend backend) +{ + public Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) => backend.BeginTaskAsync(taskId, cancellationToken); + public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => backend.EndTaskAsync(taskId, success, cancellationToken); + + public async Task DispatchAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) + { + InternalToolValidator.Validate(toolName, arguments); + ToolPolicyDecision decision = policy.Evaluate(toolName, arguments); + if (!decision.Allowed) return new(false, decision.Reason, false, $"policy:{toolName}:{decision.Reason}"); + return await backend.ExecuteAsync(taskId, toolName, arguments, cancellationToken).ConfigureAwait(false); + } +} + +public sealed class FakeInternalToolBackend : IInternalToolBackend +{ + private readonly List<(string Name, JsonElement Arguments)> _calls = []; + public IReadOnlyList<(string Name, JsonElement Arguments)> Calls => _calls; + public Task ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) + { + _calls.Add((toolName, arguments.Clone())); + bool effect = toolName is "file_edit" or "process_run"; + return Task.FromResult(new ToolExecutionResult(true, "fake-ok", effect)); + } +} diff --git a/src/Agent.Core/Tools/InternalToolValidator.cs b/src/Agent.Core/Tools/InternalToolValidator.cs new file mode 100644 index 0000000..c48719e --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolValidator.cs @@ -0,0 +1,86 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public static class InternalToolValidator +{ + public static void Validate(string name, JsonElement arguments) + { + InternalToolDefinition definition = InternalToolCatalog.Get(name); + if (arguments.ValueKind != JsonValueKind.Object) throw new ToolValidationException("Tool arguments must be an object."); + JsonElement properties = definition.Parameters.GetProperty("properties"); + var known = properties.EnumerateObject().Select(static item => item.Name).ToHashSet(StringComparer.Ordinal); + foreach (JsonProperty argument in arguments.EnumerateObject()) + { + if (!known.Contains(argument.Name)) throw new ToolValidationException($"Unknown argument '{argument.Name}'."); + ValidateValue(argument.Name, argument.Value, properties.GetProperty(argument.Name)); + } + foreach (JsonElement required in definition.Parameters.GetProperty("required").EnumerateArray()) + { + string requiredName = required.GetString()!; + if (!arguments.TryGetProperty(requiredName, out _)) throw new ToolValidationException($"Missing required argument '{requiredName}'."); + } + ValidateOperationSpecific(name, arguments); + } + + private static void ValidateValue(string name, JsonElement value, JsonElement schema) + { + if (value.ValueKind == JsonValueKind.Null && AllowsNull(schema)) return; + bool validType = GetTypes(schema).Any(type => type switch + { + "string" => value.ValueKind == JsonValueKind.String, + "integer" => value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out _), + "array" => value.ValueKind == JsonValueKind.Array, + _ => false + }); + if (!validType) throw new ToolValidationException($"Argument '{name}' has an invalid type."); + if (schema.TryGetProperty("enum", out JsonElement enumValues) && !enumValues.EnumerateArray().Any(item => JsonElement.DeepEquals(item, value))) + throw new ToolValidationException($"Argument '{name}' has an unsupported value."); + if (value.ValueKind == JsonValueKind.Number) + { + long number = value.GetInt64(); + if (schema.TryGetProperty("minimum", out JsonElement minimum) && number < minimum.GetInt64()) throw new ToolValidationException($"Argument '{name}' is below its minimum."); + if (schema.TryGetProperty("maximum", out JsonElement maximum) && number > maximum.GetInt64()) throw new ToolValidationException($"Argument '{name}' exceeds its maximum."); + } + if (value.ValueKind == JsonValueKind.Array && schema.TryGetProperty("items", out JsonElement items)) + foreach (JsonElement item in value.EnumerateArray()) ValidateValue(name, item, items); + } + + private static IEnumerable GetTypes(JsonElement schema) + { + JsonElement type = schema.GetProperty("type"); + return type.ValueKind == JsonValueKind.Array + ? type.EnumerateArray().Select(static item => item.GetString()!).ToArray() + : [type.GetString()!]; + } + private static bool AllowsNull(JsonElement schema) => GetTypes(schema).Contains("null", StringComparer.Ordinal); + + private static void ValidateOperationSpecific(string name, JsonElement arguments) + { + if (name == "file_read" && arguments.GetProperty("mode").GetString() == "range" && !arguments.TryGetProperty("length", out _)) + throw new ToolValidationException("Range reads require length."); + if (name == "file_edit") + { + string operation = arguments.GetProperty("operation").GetString()!; + if (operation is "overwrite" or "atomic_replace" && (!arguments.TryGetProperty("content", out JsonElement content) || content.ValueKind != JsonValueKind.String)) + throw new ToolValidationException($"{operation} requires content."); + if (operation == "patch" && (!arguments.TryGetProperty("patch", out JsonElement patch) || patch.ValueKind != JsonValueKind.String)) + throw new ToolValidationException("patch requires patch text."); + } + if (name == "task_update") + { + string operation = arguments.GetProperty("operation").GetString()!; + if (operation == "add") Require(arguments, "description", "budget_class", "optimistic_tokens", "most_likely_tokens", "pessimistic_tokens"); + if (operation == "add_dependency") Require(arguments, "task_id", "depends_on"); + if (operation == "set_state") Require(arguments, "task_id", "state"); + if (operation == "set_estimate") Require(arguments, "task_id", "budget_class", "optimistic_tokens", "most_likely_tokens", "pessimistic_tokens"); + } + + static void Require(JsonElement value, params string[] properties) + { + foreach (string property in properties) + if (!value.TryGetProperty(property, out JsonElement item) || item.ValueKind == JsonValueKind.Null) + throw new ToolValidationException($"{property} is required for this operation."); + } + } +} diff --git a/src/Agent.Core/Tools/TaskGraphToolHandler.cs b/src/Agent.Core/Tools/TaskGraphToolHandler.cs new file mode 100644 index 0000000..03daa56 --- /dev/null +++ b/src/Agent.Core/Tools/TaskGraphToolHandler.cs @@ -0,0 +1,123 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Agent.Planner; + +namespace Agent.Core.Tools; + +public interface ITaskUpdateHandler +{ + void BeginTask(Guid taskId); + ToolExecutionResult Execute(Guid taskId, JsonElement arguments); + void EndTask(Guid taskId); +} + +public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler +{ + private readonly ConcurrentDictionary _graphs = new(); + + public void BeginTask(Guid taskId) + { + if (!_graphs.TryAdd(taskId, new TaskGraph(maxTasks))) + throw new InvalidOperationException($"TaskGraph for '{taskId}' already exists."); + } + + public ToolExecutionResult Execute(Guid taskId, JsonElement arguments) + { + if (!_graphs.TryGetValue(taskId, out TaskGraph? graph)) + throw new InvalidOperationException("TaskGraph scope is not active."); + + string operation = arguments.GetProperty("operation").GetString()!; + switch (operation) + { + case "add": + graph.AddTask( + RequiredString(arguments, "description"), + Budget(arguments), + Estimate(arguments), + Ids(arguments, "depends_on"), + id: OptionalId(arguments, "task_id")); + break; + case "add_dependency": + Guid taskIdValue = RequiredId(arguments, "task_id"); + Guid[] dependencies = Ids(arguments, "depends_on"); + if (dependencies.Length == 0) throw new ToolValidationException("depends_on must contain at least one task id."); + foreach (Guid dependency in dependencies) graph.AddDependency(taskIdValue, dependency); + break; + case "set_state": + graph.SetState(RequiredId(arguments, "task_id"), ParseState(RequiredString(arguments, "state"))); + break; + case "set_estimate": + graph.SetEstimate(RequiredId(arguments, "task_id"), Budget(arguments), Estimate(arguments)); + break; + default: + throw new ToolValidationException($"Unsupported task_update operation '{operation}'."); + } + + string output = JsonSerializer.Serialize(new + { + tasks = graph.Tasks.Select(static task => new + { + task_id = task.Id, + description = task.Description, + depends_on = task.Dependencies, + state = task.State.ToString().ToLowerInvariant(), + expected_tokens = task.ExpectedTokens + }), + critical_path = graph.CriticalPath.TaskIds, + critical_path_tokens = graph.CriticalPath.CumulativeTokenCost, + total_token_budget = graph.TotalTokenBudget + }); + return new ToolExecutionResult(true, output, false); + } + + public void EndTask(Guid taskId) => _graphs.TryRemove(taskId, out _); + + private static TokenBudgetClass Budget(JsonElement arguments) => + TokenBudgetClassExtensions.Parse(RequiredString(arguments, "budget_class")); + + private static TokenEstimate Estimate(JsonElement arguments) => new( + RequiredInt64(arguments, "optimistic_tokens"), + RequiredInt64(arguments, "most_likely_tokens"), + RequiredInt64(arguments, "pessimistic_tokens")); + + private static string RequiredString(JsonElement arguments, string property) => + arguments.TryGetProperty(property, out JsonElement value) && value.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(value.GetString()) + ? value.GetString()! + : throw new ToolValidationException($"{property} is required for this operation."); + + private static long RequiredInt64(JsonElement arguments, string property) => + arguments.TryGetProperty(property, out JsonElement value) && value.ValueKind == JsonValueKind.Number + ? value.GetInt64() + : throw new ToolValidationException($"{property} is required for this operation."); + + private static Guid RequiredId(JsonElement arguments, string property) => + OptionalId(arguments, property) ?? throw new ToolValidationException($"{property} must be a UUID for this operation."); + + private static Guid? OptionalId(JsonElement arguments, string property) + { + if (!arguments.TryGetProperty(property, out JsonElement value) || value.ValueKind == JsonValueKind.Null) return null; + return value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out Guid id) + ? id + : throw new ToolValidationException($"{property} must be a UUID."); + } + + private static Guid[] Ids(JsonElement arguments, string property) + { + if (!arguments.TryGetProperty(property, out JsonElement value)) return []; + var ids = new List(); + foreach (JsonElement item in value.EnumerateArray()) + ids.Add(Guid.TryParse(item.GetString(), out Guid id) ? id : throw new ToolValidationException($"{property} must contain only UUIDs.")); + return [.. ids]; + } + + private static PlannerTaskState ParseState(string state) => state switch + { + "queued" => PlannerTaskState.Queued, + "planning" => PlannerTaskState.Planning, + "running" => PlannerTaskState.Running, + "completed" => PlannerTaskState.Completed, + "failed" => PlannerTaskState.Failed, + "cancelled" => PlannerTaskState.Cancelled, + _ => throw new ToolValidationException($"Unknown task state '{state}'.") + }; +} diff --git a/src/Agent.Core/Tools/ToolPolicy.cs b/src/Agent.Core/Tools/ToolPolicy.cs new file mode 100644 index 0000000..c9f216a --- /dev/null +++ b/src/Agent.Core/Tools/ToolPolicy.cs @@ -0,0 +1,31 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public sealed record ToolPolicyDecision(bool Allowed, string Reason) +{ + public static ToolPolicyDecision Allow() => new(true, "allowed"); + public static ToolPolicyDecision Deny(string reason) => new(false, reason); +} + +public interface IToolPolicyEngine +{ + ToolPolicyDecision Evaluate(string toolName, JsonElement arguments); +} + +public interface IInternalToolBackend +{ + Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) => Task.CompletedTask; + Task ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken); + Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => Task.CompletedTask; +} + +public sealed record ToolExecutionResult(bool Success, string Output, bool ExternalEffect, string? FailureFingerprint = null); + +public sealed class DenyByDefaultPolicy(IReadOnlySet? allowedTools = null) : IToolPolicyEngine +{ + private readonly IReadOnlySet _allowedTools = allowedTools ?? new HashSet(StringComparer.Ordinal); + public ToolPolicyDecision Evaluate(string toolName, JsonElement arguments) => _allowedTools.Contains(toolName) + ? ToolPolicyDecision.Allow() + : ToolPolicyDecision.Deny("Capability is not allowed by policy."); +} diff --git a/src/Agent.Core/Tools/ToolUseGuard.cs b/src/Agent.Core/Tools/ToolUseGuard.cs new file mode 100644 index 0000000..633d34c --- /dev/null +++ b/src/Agent.Core/Tools/ToolUseGuard.cs @@ -0,0 +1,22 @@ +namespace Agent.Core.Tools; + +public enum ToolUseGuardDecision { Complete, Retry, Fail } + +public sealed record ToolUseGuardResult(ToolUseGuardDecision Decision, string Observation, int IdenticalFailureCount); + +public sealed class ToolUseGuard(int sameFailureLimit = 5) +{ + private readonly Dictionary _failures = new(StringComparer.Ordinal); + public int SameFailureLimit { get; } = sameFailureLimit > 0 ? sameFailureLimit : throw new ArgumentOutOfRangeException(nameof(sameFailureLimit)); + + public ToolUseGuardResult Evaluate(bool requiresExternalEffect, bool hadToolCall, bool hasExternalEffectEvidence, string? failureFingerprint = null) + { + if (!requiresExternalEffect) return new(ToolUseGuardDecision.Complete, string.Empty, 0); + if (hasExternalEffectEvidence) return new(ToolUseGuardDecision.Complete, string.Empty, 0); + string fingerprint = string.IsNullOrWhiteSpace(failureFingerprint) ? "missing-external-effect" : failureFingerprint; + int count = _failures.TryGetValue(fingerprint, out int prior) ? prior + 1 : 1; + _failures[fingerprint] = count; + if (count >= SameFailureLimit) return new(ToolUseGuardDecision.Fail, "Required external effect was not completed after repeated identical failures.", count); + return new(ToolUseGuardDecision.Retry, "No verified external effect occurred; perform the required operation using an internal tool.", count); + } +} diff --git a/src/Agent.Core/Tools/ToolValidationException.cs b/src/Agent.Core/Tools/ToolValidationException.cs new file mode 100644 index 0000000..a99472c --- /dev/null +++ b/src/Agent.Core/Tools/ToolValidationException.cs @@ -0,0 +1,3 @@ +namespace Agent.Core.Tools; + +public sealed class ToolValidationException(string message) : InvalidOperationException(message); diff --git a/src/Agent.Mcp/Agent.Mcp.csproj b/src/Agent.Mcp/Agent.Mcp.csproj new file mode 100644 index 0000000..5b09556 --- /dev/null +++ b/src/Agent.Mcp/Agent.Mcp.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/Agent.Mcp/AgentTools.cs b/src/Agent.Mcp/AgentTools.cs new file mode 100644 index 0000000..c44d6ec --- /dev/null +++ b/src/Agent.Mcp/AgentTools.cs @@ -0,0 +1,90 @@ +using System.ComponentModel; +using Agent.Core.Localization; +using Agent.Core.Tasks; +using ModelContextProtocol.Server; +using ModelContextProtocol.Protocol; + +namespace Agent.Mcp; + +[McpServerToolType] +public sealed class AgentTools(TaskRegistry registry, LocaleCatalog locale, VisionLimits visionLimits) +{ + [McpServerTool(Name = "agent_run", ReadOnly = false, Destructive = true, OpenWorld = false, UseStructuredContent = false)] + [Description("Queue a natural-language request for asynchronous execution.")] + public string AgentRun( + RequestContext context, + [Description("Non-empty natural-language work request.")] string request, + [Description("Existing session UUID, or omit to create a session.")] Guid? session_id = null, + [Description("Task-scoped temperature from 0.0 through 2.0.")] double? temperature = null, + [Description("Optional PNG, JPEG, or WebP images.")] IReadOnlyList? images = null) + { + RequireOnly(context, "request", "session_id", "temperature", "images"); + ValidateImages(images); + TaskSnapshot task = registry.Enqueue(request, session_id, temperature, images?.Select(static image => image.DataBase64).ToArray()); + string message = locale.Format("mcp.task_queued", new Dictionary + { + ["task"] = task.TaskId, + ["session"] = task.SessionId + }); + return string.Join('\n', + $"task_id: {task.TaskId}", + $"session_id: {task.SessionId}", + "status: queued", + $"message: {message}"); + } + + private void ValidateImages(IReadOnlyList? images) + { + if (images is null) return; + if (images.Count > visionLimits.MaxCount) throw new ArgumentException($"Image count exceeds configured limit {visionLimits.MaxCount}."); + long total = 0; + foreach (McpImageInput image in images) + { + if (image.MimeType is not ("image/png" or "image/jpeg" or "image/webp")) throw new ArgumentException($"Unsupported image MIME type '{image.MimeType}'."); + byte[] bytes; + try { bytes = Convert.FromBase64String(image.DataBase64); } + catch (FormatException ex) { throw new ArgumentException("Image data_base64 is invalid.", ex); } + if (bytes.LongLength > visionLimits.MaxBytesEach) throw new ArgumentException($"Image exceeds configured per-image limit {visionLimits.MaxBytesEach} bytes."); + total = checked(total + bytes.LongLength); + if (total > visionLimits.MaxTotalBytes) throw new ArgumentException($"Images exceed configured total limit {visionLimits.MaxTotalBytes} bytes."); + } + } + + [McpServerTool(Name = "agent_status", ReadOnly = true, Destructive = false, OpenWorld = false, UseStructuredContent = false)] + [Description("Get the current state and result of a queued agent task.")] + public string AgentStatus( + RequestContext context, + [Description("Task UUID returned by agent_run.")] Guid task_id, + [Description("Optional session UUID used to verify task ownership.")] Guid? session_id = null) + { + RequireOnly(context, "task_id", "session_id"); + if (!registry.TryGet(task_id, session_id, out TaskSnapshot? task) || task is null) + { + return string.Join('\n', + $"task_id: {task_id}", + "status: failed", + $"message: {locale.Format("mcp.task_not_found", new Dictionary { ["task"] = task_id })}"); + } + + string state = task.State.ToString().ToLowerInvariant(); + bool terminal = task.State is AgentTaskState.Completed or AgentTaskState.Failed or AgentTaskState.Cancelled; + return string.Join('\n', + $"task_id: {task.TaskId}", + $"session_id: {task.SessionId}", + $"status: {state}", + $"completed_tasks: {task.CompletedTasks}/{task.TotalTasks}", + $"current_task: {task.CurrentTask ?? string.Empty}", + $"token_usage: input={task.InputTokens} output={task.OutputTokens} total={task.InputTokens + task.OutputTokens}", + $"critical_path_tokens: {task.CriticalPathTokens.ToString(System.Globalization.CultureInfo.InvariantCulture)}", + $"message: {locale.Get("mcp.status." + state)}", + terminal ? $"result: {task.Result}" : "result:"); + } + + private static void RequireOnly(RequestContext context, params string[] allowed) + { + if (context.Params?.Arguments is null) return; + HashSet allowedSet = allowed.ToHashSet(StringComparer.Ordinal); + string? unknown = context.Params.Arguments.Keys.FirstOrDefault(key => !allowedSet.Contains(key)); + if (unknown is not null) throw new ArgumentException($"Unknown tool property '{unknown}'."); + } +} diff --git a/src/Agent.Mcp/AssemblyMarker.cs b/src/Agent.Mcp/AssemblyMarker.cs new file mode 100644 index 0000000..ef6d87f --- /dev/null +++ b/src/Agent.Mcp/AssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace Agent.Mcp; + +public static class AssemblyMarker; diff --git a/src/Agent.Mcp/McpHost.cs b/src/Agent.Mcp/McpHost.cs new file mode 100644 index 0000000..ff546e0 --- /dev/null +++ b/src/Agent.Mcp/McpHost.cs @@ -0,0 +1,136 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Text.Json.Nodes; +using System.Reflection; +using Agent.Core.Localization; +using Agent.Core.Tasks; +using Agent.Core.Ollama; +using Agent.Core.Runtime; +using Agent.Core.Tools; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using ModelContextProtocol.Server; + +namespace Agent.Mcp; + +public static class McpHost +{ + public static async Task RunStdioAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null) + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings + { + Args = [], + ContentRootPath = AppContext.BaseDirectory + }); + builder.Logging.ClearProviders(); + var registry = new TaskRegistry(); + using var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; + var ollama = new OllamaClient(httpClient, ollamaSettings); + var runtime = new AgentRuntime(registry, ollama, runtimeSettings, toolDispatcher, observer); + builder.Services.AddSingleton(locale); + builder.Services.AddSingleton(registry); + builder.Services.AddSingleton(runtime); + builder.Services.AddHostedService(); + + McpServerTool[] tools = CreateTools(registry, locale, visionLimits); + + builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(tools); + + using IHost host = builder.Build(); + await host.RunAsync(cancellationToken).ConfigureAwait(false); + } + + public static async Task RunHttpAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, string hostName, int port, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null) + { + if (!System.Net.IPAddress.TryParse(hostName, out System.Net.IPAddress? address) || !System.Net.IPAddress.IsLoopback(address)) + throw new ArgumentException("HTTP transport host must be a loopback IP literal.", nameof(hostName)); + WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [], ContentRootPath = AppContext.BaseDirectory }); + builder.Logging.ClearProviders(); + string listenHost = address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? $"[{address}]" : address.ToString(); + builder.WebHost.UseUrls($"http://{listenHost}:{port}"); + var registry = new TaskRegistry(); + var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; + var runtime = new AgentRuntime(registry, new OllamaClient(httpClient, ollamaSettings), runtimeSettings, toolDispatcher, observer); + builder.Services.AddSingleton(locale); + builder.Services.AddSingleton(registry); + builder.Services.AddSingleton(runtime); + builder.Services.AddHostedService(); + McpServerTool[] tools = CreateTools(registry, locale, visionLimits); + builder.Services.AddMcpServer().WithHttpTransport().WithTools(tools); + await using WebApplication app = builder.Build(); + app.Use(async (context, next) => + { + if (context.Request.Headers.TryGetValue("Origin", out Microsoft.Extensions.Primitives.StringValues origins) && + origins.Any(origin => !IsLoopbackOrigin(origin))) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + if (context.Request.Path == "/mcp" && HttpMethods.IsGet(context.Request.Method)) + { + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + return; + } + await next(context).ConfigureAwait(false); + }); + app.MapMcp("/mcp"); + try { await app.RunAsync(cancellationToken).ConfigureAwait(false); } + finally { httpClient.Dispose(); } + } + + private static bool IsLoopbackOrigin(string? origin) + { + return Uri.TryCreate(origin, UriKind.Absolute, out Uri? uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) && + (string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase) || + (System.Net.IPAddress.TryParse(uri.Host, out System.Net.IPAddress? address) && System.Net.IPAddress.IsLoopback(address))); + } + + private static McpServerTool[] CreateTools(TaskRegistry registry, LocaleCatalog locale, VisionLimits visionLimits) + { + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow + }; + var target = new AgentTools(registry, locale, visionLimits); + McpServerTool[] tools = typeof(AgentTools) + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly) + .Where(static method => method.GetCustomAttribute() is not null) + .Select(method => McpServerTool.Create(method, target, new McpServerToolCreateOptions { SerializerOptions = serializerOptions })) + .ToArray(); + foreach (McpServerTool tool in tools) MakeSchemaStrict(tool); + return tools; + } + + private static void MakeSchemaStrict(McpServerTool tool) + { + JsonNode root = JsonNode.Parse(tool.ProtocolTool.InputSchema.GetRawText()) + ?? throw new InvalidOperationException($"Tool '{tool.ProtocolTool.Name}' has no input schema."); + Visit(root); + tool.ProtocolTool.InputSchema = JsonSerializer.SerializeToElement(root); + + static void Visit(JsonNode? node) + { + if (node is JsonObject obj) + { + if (obj["type"] is JsonValue type && type.TryGetValue(out string? typeName) && typeName == "object") + obj["additionalProperties"] = false; + foreach (KeyValuePair property in obj.ToArray()) Visit(property.Value); + } + else if (node is JsonArray array) + { + foreach (JsonNode? item in array) Visit(item); + } + } + } +} diff --git a/src/Agent.Mcp/McpImageInput.cs b/src/Agent.Mcp/McpImageInput.cs new file mode 100644 index 0000000..9c0bd81 --- /dev/null +++ b/src/Agent.Mcp/McpImageInput.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Agent.Mcp; + +public sealed class McpImageInput +{ + [JsonPropertyName("mime_type")] + public required string MimeType { get; init; } + + [JsonPropertyName("data_base64")] + public required string DataBase64 { get; init; } +} diff --git a/src/Agent.Mcp/QueuedTaskWorker.cs b/src/Agent.Mcp/QueuedTaskWorker.cs new file mode 100644 index 0000000..23404e3 --- /dev/null +++ b/src/Agent.Mcp/QueuedTaskWorker.cs @@ -0,0 +1,18 @@ +using Agent.Core.Tasks; +using Microsoft.Extensions.Hosting; +using Agent.Core.Runtime; + +namespace Agent.Mcp; + +public sealed class QueuedTaskWorker(TaskRegistry registry, AgentRuntime runtime) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (Guid taskId in registry.ReadQueuedAsync(stoppingToken).ConfigureAwait(false)) + { + if (!registry.Transition(taskId, AgentTaskState.Planning, "planning")) continue; + registry.Transition(taskId, AgentTaskState.Running, "ollama chat"); + await runtime.ExecuteAsync(taskId, stoppingToken).ConfigureAwait(false); + } + } +} diff --git a/src/Agent.Mcp/VisionLimits.cs b/src/Agent.Mcp/VisionLimits.cs new file mode 100644 index 0000000..ad6dfb3 --- /dev/null +++ b/src/Agent.Mcp/VisionLimits.cs @@ -0,0 +1,3 @@ +namespace Agent.Mcp; + +public sealed record VisionLimits(int MaxCount = 4, long MaxBytesEach = 10 * 1024 * 1024, long MaxTotalBytes = 20 * 1024 * 1024); diff --git a/src/Agent.Planner/Agent.Planner.csproj b/src/Agent.Planner/Agent.Planner.csproj new file mode 100644 index 0000000..6b512ec --- /dev/null +++ b/src/Agent.Planner/Agent.Planner.csproj @@ -0,0 +1 @@ + diff --git a/src/Agent.Planner/AssemblyMarker.cs b/src/Agent.Planner/AssemblyMarker.cs new file mode 100644 index 0000000..e90784d --- /dev/null +++ b/src/Agent.Planner/AssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace Agent.Planner; + +public static class AssemblyMarker; diff --git a/src/Agent.Planner/PlannerTask.cs b/src/Agent.Planner/PlannerTask.cs new file mode 100644 index 0000000..edc85c2 --- /dev/null +++ b/src/Agent.Planner/PlannerTask.cs @@ -0,0 +1,18 @@ +namespace Agent.Planner; + +public sealed record PlannerTask( + Guid Id, + string Description, + IReadOnlySet Dependencies, + PlannerTaskState State, + TokenBudgetClass BudgetClass, + TokenEstimate Estimate, + long ActualInputTokens, + long ActualOutputTokens, + bool RequiresExternalEffect) +{ + public double ExpectedTokens => Estimate.ExpectedTokens; + public long ActualTotalTokens => checked(ActualInputTokens + ActualOutputTokens); +} + +public sealed record CriticalPathResult(IReadOnlyList TaskIds, double CumulativeTokenCost); diff --git a/src/Agent.Planner/PlannerTaskState.cs b/src/Agent.Planner/PlannerTaskState.cs new file mode 100644 index 0000000..d0b79bf --- /dev/null +++ b/src/Agent.Planner/PlannerTaskState.cs @@ -0,0 +1,11 @@ +namespace Agent.Planner; + +public enum PlannerTaskState +{ + Queued, + Planning, + Running, + Completed, + Failed, + Cancelled +} diff --git a/src/Agent.Planner/PlannerValidationException.cs b/src/Agent.Planner/PlannerValidationException.cs new file mode 100644 index 0000000..934d549 --- /dev/null +++ b/src/Agent.Planner/PlannerValidationException.cs @@ -0,0 +1,3 @@ +namespace Agent.Planner; + +public sealed class PlannerValidationException(string message) : InvalidOperationException(message); diff --git a/src/Agent.Planner/TaskGraph.cs b/src/Agent.Planner/TaskGraph.cs new file mode 100644 index 0000000..7a4d45b --- /dev/null +++ b/src/Agent.Planner/TaskGraph.cs @@ -0,0 +1,179 @@ +namespace Agent.Planner; + +public sealed class TaskGraph(int maxTasks = 32) +{ + private readonly Dictionary _tasks = []; + private readonly List _insertionOrder = []; + private CriticalPathResult _criticalPath = new([], 0); + + public int MaxTasks { get; } = maxTasks > 0 ? maxTasks : throw new ArgumentOutOfRangeException(nameof(maxTasks)); + public int Count => _tasks.Count; + public CriticalPathResult CriticalPath => _criticalPath; + public long TotalTokenBudget => _tasks.Values.Aggregate(0L, static (total, task) => checked(total + task.BudgetClass.MaxTokens())); + public IReadOnlyList Tasks => _insertionOrder.Select(id => _tasks[id].Snapshot()).ToArray(); + + public PlannerTask AddTask( + string description, + TokenBudgetClass budgetClass, + TokenEstimate estimate, + IEnumerable? dependencies = null, + bool requiresExternalEffect = false, + Guid? id = null) + { + if (string.IsNullOrWhiteSpace(description)) throw new PlannerValidationException("Task description is required."); + if (_tasks.Count >= MaxTasks) throw new PlannerValidationException($"Task limit of {MaxTasks} exceeded."); + estimate.Validate(budgetClass); + Guid taskId = id ?? Guid.NewGuid(); + if (_tasks.ContainsKey(taskId)) throw new PlannerValidationException($"Task '{taskId}' already exists."); + HashSet dependencySet = dependencies?.ToHashSet() ?? []; + EnsureDependenciesExist(dependencySet); + if (dependencySet.Contains(taskId)) throw new PlannerValidationException("A task cannot depend on itself."); + + _tasks.Add(taskId, new MutableTask(taskId, description, dependencySet, budgetClass, estimate, requiresExternalEffect)); + _insertionOrder.Add(taskId); + try + { + Recalculate(); + } + catch + { + _tasks.Remove(taskId); + _insertionOrder.Remove(taskId); + throw; + } + return _tasks[taskId].Snapshot(); + } + + public void AddDependency(Guid taskId, Guid dependencyId) + { + MutableTask task = GetMutable(taskId); + EnsureMutable(task); + _ = GetMutable(dependencyId); + if (!task.Dependencies.Add(dependencyId)) return; + try + { + Recalculate(); + } + catch + { + task.Dependencies.Remove(dependencyId); + throw; + } + } + + public void SetEstimate(Guid taskId, TokenBudgetClass budgetClass, TokenEstimate estimate) + { + MutableTask task = GetMutable(taskId); + EnsureMutable(task); + estimate.Validate(budgetClass); + (TokenBudgetClass previousBudget, TokenEstimate previousEstimate) = (task.BudgetClass, task.Estimate); + task.BudgetClass = budgetClass; + task.Estimate = estimate; + try { Recalculate(); } + catch + { + task.BudgetClass = previousBudget; + task.Estimate = previousEstimate; + throw; + } + } + + public void SetState(Guid taskId, PlannerTaskState state) + { + MutableTask task = GetMutable(taskId); + EnsureMutable(task); + task.State = state; + } + + public void AddUsage(Guid taskId, long inputTokens, long outputTokens) + { + if (inputTokens < 0 || outputTokens < 0) throw new ArgumentOutOfRangeException(nameof(inputTokens), "Token usage cannot be negative."); + MutableTask task = GetMutable(taskId); + EnsureMutable(task); + task.ActualInputTokens = checked(task.ActualInputTokens + inputTokens); + task.ActualOutputTokens = checked(task.ActualOutputTokens + outputTokens); + } + + public PlannerTask Get(Guid taskId) => GetMutable(taskId).Snapshot(); + + private void Recalculate() + { + IReadOnlyList order = TopologicalOrder(); + var costs = new Dictionary(); + var predecessors = new Dictionary(); + foreach (Guid id in order) + { + MutableTask task = _tasks[id]; + Guid? best = null; + double bestCost = 0; + foreach (Guid dependency in task.Dependencies.OrderBy(InsertionIndex)) + { + double candidate = costs[dependency]; + if (best is null || candidate > bestCost) + { + best = dependency; + bestCost = candidate; + } + } + costs[id] = bestCost + task.Estimate.ExpectedTokens; + predecessors[id] = best; + } + if (order.Count == 0) + { + _criticalPath = new([], 0); + return; + } + Guid end = order[0]; + foreach (Guid id in order.Skip(1)) if (costs[id] > costs[end]) end = id; + var path = new List(); + for (Guid? current = end; current is not null; current = predecessors[current.Value]) path.Add(current.Value); + path.Reverse(); + _criticalPath = new(path, costs[end]); + } + + private IReadOnlyList TopologicalOrder() + { + var indegree = _tasks.ToDictionary(static pair => pair.Key, static pair => pair.Value.Dependencies.Count); + var ready = new SortedSet(_insertionOrder.Select((id, index) => (id, index)).Where(item => indegree[item.id] == 0).Select(static item => item.index)); + var result = new List(_tasks.Count); + while (ready.Count > 0) + { + int index = ready.Min; + ready.Remove(index); + Guid id = _insertionOrder[index]; + result.Add(id); + foreach (Guid dependent in _insertionOrder.Where(candidate => _tasks[candidate].Dependencies.Contains(id))) + { + indegree[dependent]--; + if (indegree[dependent] == 0) ready.Add(InsertionIndex(dependent)); + } + } + if (result.Count != _tasks.Count) throw new PlannerValidationException("TaskGraph must be a DAG; the update creates a cycle."); + return result; + } + + private int InsertionIndex(Guid id) => _insertionOrder.IndexOf(id); + private MutableTask GetMutable(Guid id) => _tasks.TryGetValue(id, out MutableTask? task) ? task : throw new KeyNotFoundException($"Task '{id}' was not found."); + private void EnsureDependenciesExist(IEnumerable dependencies) + { + foreach (Guid dependency in dependencies) _ = GetMutable(dependency); + } + private static void EnsureMutable(MutableTask task) + { + if (task.State == PlannerTaskState.Completed) throw new PlannerValidationException("Completed tasks are immutable."); + } + + private sealed class MutableTask(Guid id, string description, HashSet dependencies, TokenBudgetClass budgetClass, TokenEstimate estimate, bool requiresExternalEffect) + { + public Guid Id { get; } = id; + public string Description { get; } = description; + public HashSet Dependencies { get; } = dependencies; + public PlannerTaskState State { get; set; } = PlannerTaskState.Queued; + public TokenBudgetClass BudgetClass { get; set; } = budgetClass; + public TokenEstimate Estimate { get; set; } = estimate; + public long ActualInputTokens { get; set; } + public long ActualOutputTokens { get; set; } + public bool RequiresExternalEffect { get; } = requiresExternalEffect; + public PlannerTask Snapshot() => new(Id, Description, Dependencies.ToHashSet(), State, BudgetClass, Estimate, ActualInputTokens, ActualOutputTokens, RequiresExternalEffect); + } +} diff --git a/src/Agent.Planner/TokenBudgetClass.cs b/src/Agent.Planner/TokenBudgetClass.cs new file mode 100644 index 0000000..35894c7 --- /dev/null +++ b/src/Agent.Planner/TokenBudgetClass.cs @@ -0,0 +1,31 @@ +namespace Agent.Planner; + +public enum TokenBudgetClass +{ + K1 = 1_024, + K2 = 2_048, + K4 = 4_096, + K8 = 8_192, + K16 = 16_384, + K32 = 32_768, + K64 = 65_536, + K128 = 131_072 +} + +public static class TokenBudgetClassExtensions +{ + public static long MaxTokens(this TokenBudgetClass value) => (long)value; + + public static TokenBudgetClass Parse(string value) => value switch + { + "1K" => TokenBudgetClass.K1, + "2K" => TokenBudgetClass.K2, + "4K" => TokenBudgetClass.K4, + "8K" => TokenBudgetClass.K8, + "16K" => TokenBudgetClass.K16, + "32K" => TokenBudgetClass.K32, + "64K" => TokenBudgetClass.K64, + "128K" => TokenBudgetClass.K128, + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown token budget class.") + }; +} diff --git a/src/Agent.Planner/TokenEstimate.cs b/src/Agent.Planner/TokenEstimate.cs new file mode 100644 index 0000000..4b8920a --- /dev/null +++ b/src/Agent.Planner/TokenEstimate.cs @@ -0,0 +1,15 @@ +namespace Agent.Planner; + +public readonly record struct TokenEstimate(long Optimistic, long MostLikely, long Pessimistic) +{ + public double ExpectedTokens => (Optimistic + (4d * MostLikely) + Pessimistic) / 6d; + public double VarianceTokens => Math.Pow((Pessimistic - Optimistic) / 6d, 2); + + public void Validate(TokenBudgetClass budgetClass) + { + if (Optimistic <= 0 || Optimistic > MostLikely || MostLikely > Pessimistic || Pessimistic > budgetClass.MaxTokens()) + { + throw new PlannerValidationException("Estimate must satisfy 0 < O <= M <= P <= token budget."); + } + } +} diff --git a/src/Agent.Windows/Agent.Windows.csproj b/src/Agent.Windows/Agent.Windows.csproj new file mode 100644 index 0000000..ff2f299 --- /dev/null +++ b/src/Agent.Windows/Agent.Windows.csproj @@ -0,0 +1,17 @@ + + + net10.0-windows10.0.26100.0 + true + true + $(IntermediateOutputPath)Generated + + + + + + + + + + + diff --git a/src/Agent.Windows/AssemblyMarker.cs b/src/Agent.Windows/AssemblyMarker.cs new file mode 100644 index 0000000..7426ca0 --- /dev/null +++ b/src/Agent.Windows/AssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace Agent.Windows; + +public static class AssemblyMarker; diff --git a/src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs b/src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs new file mode 100644 index 0000000..b8ca0ae --- /dev/null +++ b/src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs @@ -0,0 +1,50 @@ +using System.ComponentModel; +using System.Security.Principal; +using Agent.Windows.Network; +using Agent.Windows.Security; + +namespace Agent.Windows.Diagnostics; + +public sealed record WindowsPlatformCheckResult( + bool IsAdministrator, + bool BfeEngineOpen, + bool WfpWriteTransaction, + bool WfpFilterAddDelete, + int? ErrorCode, + string? ErrorOperation) +{ + public bool Success => IsAdministrator && BfeEngineOpen && WfpWriteTransaction && WfpFilterAddDelete; +} + +public static class WindowsPlatformCheck +{ + public static WindowsPlatformCheckResult Run() + { + using WindowsIdentity identity = WindowsIdentity.GetCurrent(); + bool administrator = new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); + bool open = false; + bool transaction = false; + bool filters = false; + try + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using WfpDynamicSession session = WfpDynamicSession.Open(); + open = true; + session.ProbeWriteTransaction(); + transaction = true; + IReadOnlyList ids = session.ApplyDefaultBlock(profile.Sid); + if (ids.Count != 2 || ids.Any(static id => id == 0)) throw new InvalidOperationException("WFP probe did not create both IPv4 and IPv6 filters."); + session.RemovePolicy(); + filters = true; + return new(administrator, open, transaction, filters, null, null); + } + catch (Win32Exception ex) + { + return new(administrator, open, transaction, filters, ex.NativeErrorCode, ex.Message); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or InvalidOperationException or IOException) + { + return new(administrator, open, transaction, filters, ex.HResult, ex.Message); + } + } +} diff --git a/src/Agent.Windows/NativeMethods.txt b/src/Agent.Windows/NativeMethods.txt new file mode 100644 index 0000000..471b47d --- /dev/null +++ b/src/Agent.Windows/NativeMethods.txt @@ -0,0 +1,46 @@ +AssignProcessToJobObject +CloseHandle +CreateJobObject +CreateProcess +DeleteProcThreadAttributeList +GetExitCodeProcess +InitializeProcThreadAttributeList +JOBOBJECT_EXTENDED_LIMIT_INFORMATION +JOB_OBJECT_LIMIT +PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY +PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES +PROC_THREAD_ATTRIBUTE_HANDLE_LIST +ResumeThread +SetHandleInformation +SetInformationJobObject +TerminateJobObject +UpdateProcThreadAttribute +STARTUPINFOEXW +WaitForSingleObject +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineOpen0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineClose0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionBegin0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionCommit0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionAbort0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmSubLayerAdd0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmSubLayerDeleteByKey0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmFilterAdd0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmFilterDeleteById0 +FWPM_SESSION0 +FWPM_SESSION_FLAG_DYNAMIC +FWPM_SUBLAYER0 +FWPM_FILTER0 +FWPM_FILTER_CONDITION0 +FWPM_LAYER_ALE_AUTH_CONNECT_V4 +FWPM_LAYER_ALE_AUTH_CONNECT_V6 +FWPM_CONDITION_ALE_PACKAGE_ID +FWPM_CONDITION_IP_REMOTE_ADDRESS +FWPM_CONDITION_IP_REMOTE_PORT +FWP_BYTE_ARRAY16 +FWP_ACTION_TYPE +FWP_MATCH_TYPE +FWP_DATA_TYPE +NetworkIsolationGetAppContainerConfig +NetworkIsolationSetAppContainerConfig +GetProcessHeap +HeapFree diff --git a/src/Agent.Windows/Network/LoopbackExemption.cs b/src/Agent.Windows/Network/LoopbackExemption.cs new file mode 100644 index 0000000..a3c68ba --- /dev/null +++ b/src/Agent.Windows/Network/LoopbackExemption.cs @@ -0,0 +1,123 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Security; +using Windows.Win32.System.Memory; + +namespace Agent.Windows.Network; + +internal static unsafe class LoopbackExemption +{ + private const string MutexName = "Local\\AgentSandboxMcpLoopbackExemption"; + + public static bool Contains(SecurityIdentifier sid) + { + using MutexLease mutex = AcquireMutex(); + return Read().Any(item => item.Sid.Equals(sid)); + } + + public static bool Ensure(SecurityIdentifier sid) + { + using MutexLease mutex = AcquireMutex(); + List entries = Read(); + if (entries.Any(item => item.Sid.Equals(sid))) return false; + entries.Add(new(sid, 0)); + Write(entries); + return true; + } + + public static void Remove(SecurityIdentifier sid) + { + using MutexLease mutex = AcquireMutex(); + List entries = Read(); + if (entries.RemoveAll(item => item.Sid.Equals(sid)) == 0) return; + Write(entries); + } + + private static MutexLease AcquireMutex() + { + var mutex = new Mutex(false, MutexName); + try + { + try + { + if (!mutex.WaitOne(TimeSpan.FromSeconds(30))) throw new TimeoutException("Timed out waiting for the loopback exemption lock."); + } + catch (AbandonedMutexException) + { + // Ownership is granted to this thread when the prior owner died. + } + return new(mutex); + } + catch + { + mutex.Dispose(); + throw; + } + } + + private static List Read() + { + uint result = PInvoke.NetworkIsolationGetAppContainerConfig(out uint count, out SID_AND_ATTRIBUTES* native); + ThrowIfError(result, "NetworkIsolationGetAppContainerConfig"); + try + { + var entries = new List(checked((int)count)); + for (uint index = 0; index < count; index++) + { + SID_AND_ATTRIBUTES item = native[index]; + entries.Add(new(new SecurityIdentifier((nint)item.Sid.Value), item.Attributes)); + } + return entries; + } + finally + { + HANDLE heap = PInvoke.GetProcessHeap(); + for (uint index = 0; index < count; index++) + if (native[index].Sid.Value is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native[index].Sid.Value); + if (native is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native); + } + } + + private static void Write(IReadOnlyList entries) + { + var native = new SID_AND_ATTRIBUTES[entries.Count]; + var allocations = new List(entries.Count); + try + { + for (int index = 0; index < entries.Count; index++) + { + byte[] bytes = new byte[entries[index].Sid.BinaryLength]; + entries[index].Sid.GetBinaryForm(bytes, 0); + void* allocation = NativeMemory.Alloc((nuint)bytes.Length); + Marshal.Copy(bytes, 0, (nint)allocation, bytes.Length); + allocations.Add((nint)allocation); + native[index].Sid = new PSID(allocation); + native[index].Attributes = entries[index].Attributes; + } + ThrowIfError(PInvoke.NetworkIsolationSetAppContainerConfig(native), "NetworkIsolationSetAppContainerConfig"); + } + finally + { + foreach (nint allocation in allocations) NativeMemory.Free((void*)allocation); + } + } + + private static void ThrowIfError(uint result, string operation) + { + if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with Win32 error 0x{result:X8}."); + } + + private sealed record Entry(SecurityIdentifier Sid, uint Attributes); + + private sealed class MutexLease(Mutex mutex) : IDisposable + { + public void Dispose() + { + mutex.ReleaseMutex(); + mutex.Dispose(); + } + } +} diff --git a/src/Agent.Windows/Network/TaskNetworkPolicy.cs b/src/Agent.Windows/Network/TaskNetworkPolicy.cs new file mode 100644 index 0000000..59b2602 --- /dev/null +++ b/src/Agent.Windows/Network/TaskNetworkPolicy.cs @@ -0,0 +1,73 @@ +using System.Security.Principal; +using Agent.Windows.Recovery; +using Agent.Windows.Security; + +namespace Agent.Windows.Network; + +public sealed class TaskNetworkPolicy : IDisposable +{ + private readonly WfpDynamicSession _session; + private readonly TaskResourceJournal _journal; + private readonly ISecurityAuditSink _audit; + private readonly Guid _entryId; + private readonly SecurityIdentifier _appContainerSid; + private readonly Guid _loopbackEntryId; + private readonly bool _loopbackAdded; + private bool _disposed; + + private TaskNetworkPolicy(WfpDynamicSession session, TaskResourceJournal journal, ISecurityAuditSink audit, Guid entryId, + SecurityIdentifier appContainerSid, Guid loopbackEntryId, bool loopbackAdded) + { + _session = session; _journal = journal; _audit = audit; _entryId = entryId; + _appContainerSid = appContainerSid; _loopbackEntryId = loopbackEntryId; _loopbackAdded = loopbackAdded; + } + + public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList allowances, ISecurityAuditSink audit) + { + bool originallyExempt = LoopbackExemption.Contains(appContainerSid); + Guid loopbackEntryId = journal.Prepare("network.loopback_exemption", appContainerSid.Value, + new Dictionary { ["originally_present"] = originallyExempt.ToString() }); + bool loopbackAdded = false; + Guid entryId = journal.Prepare("wfp.dynamic", appContainerSid.Value, new Dictionary + { + ["session"] = "dynamic", ["allowances"] = string.Join(',', allowances.Select(static item => $"{item.Address}:{item.Port}")) + }); + WfpDynamicSession session = WfpDynamicSession.Open(); + try + { + loopbackAdded = LoopbackExemption.Ensure(appContainerSid); + journal.MarkApplied(loopbackEntryId, new Dictionary { ["added"] = loopbackAdded.ToString() }); + IReadOnlyList ids = session.ApplyDefaultBlock(appContainerSid, allowances); + journal.MarkApplied(entryId, new Dictionary { ["filter_ids"] = string.Join(',', ids) }); + audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "wfp.apply", "applied", new Dictionary + { + ["ipv4_ipv6_default_block"] = "true", ["filter_count"] = ids.Count.ToString(), ["session"] = "dynamic" + })); + audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", loopbackAdded ? "added" : "preserved", + new Dictionary { ["app_container_sid"] = appContainerSid.Value })); + return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded); + } + catch + { + session.Dispose(); + journal.MarkCleaned(entryId); + if (loopbackAdded) LoopbackExemption.Remove(appContainerSid); + journal.MarkCleaned(loopbackEntryId); + throw; + } + } + + public void Dispose() + { + if (_disposed) return; + _session.RemovePolicy(); + _session.Dispose(); + _journal.MarkCleaned(_entryId); + if (_loopbackAdded) LoopbackExemption.Remove(_appContainerSid); + _journal.MarkCleaned(_loopbackEntryId); + _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "wfp.remove", "removed", new Dictionary { ["residual_filters"] = "0" })); + _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed", + new Dictionary { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" })); + _disposed = true; + } +} diff --git a/src/Agent.Windows/Network/WfpDynamicSession.cs b/src/Agent.Windows/Network/WfpDynamicSession.cs new file mode 100644 index 0000000..ef8ba8e --- /dev/null +++ b/src/Agent.Windows/Network/WfpDynamicSession.cs @@ -0,0 +1,208 @@ +using System.ComponentModel; +using System.Buffers.Binary; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.NetworkManagement.WindowsFilteringPlatform; +using Windows.Win32.Security; + +namespace Agent.Windows.Network; + +public sealed record NetworkEndpoint(IPAddress Address, ushort Port); + +public static class NetworkAllowanceSnapshot +{ + public static async Task> ResolveAsync(IEnumerable<(string Host, IReadOnlyList Ports)> allowances, CancellationToken cancellationToken) + { + var endpoints = new HashSet(); + foreach ((string host, IReadOnlyList ports) in allowances) + { + IPAddress[] addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false); + foreach (int port in ports) + { + if (port is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(allowances), $"Invalid network port {port}."); + foreach (IPAddress address in addresses.Where(static item => item.AddressFamily is System.Net.Sockets.AddressFamily.InterNetwork or System.Net.Sockets.AddressFamily.InterNetworkV6)) + endpoints.Add(new(address, (ushort)port)); + } + } + return endpoints.OrderBy(static item => item.Address.ToString(), StringComparer.Ordinal).ThenBy(static item => item.Port).ToArray(); + } +} + +public sealed unsafe class WfpDynamicSession : IDisposable +{ + private const FWPM_FILTER_FLAGS FwpmFilterFlagClearActionRight = (FWPM_FILTER_FLAGS)0x00000008; + private readonly FwpmEngineClose0SafeHandle _engine; + private Guid? _subLayerKey; + private readonly List _filterIds = []; + + private WfpDynamicSession(FwpmEngineClose0SafeHandle engine) => _engine = engine; + public static WfpDynamicSession Open(string displayName = "Agent Sandbox MCP dynamic task session") + { + FWPM_SESSION0 session = default; + session.flags = PInvoke.FWPM_SESSION_FLAG_DYNAMIC; + uint result = PInvoke.FwpmEngineOpen0(10, null, session, out FwpmEngineClose0SafeHandle engine); + ThrowIfError(result, "FwpmEngineOpen0"); + return new(engine); + } + + public void ProbeWriteTransaction() + { + uint result = PInvoke.FwpmTransactionBegin0(_engine, 0); + ThrowIfError(result, "FwpmTransactionBegin0"); + result = PInvoke.FwpmTransactionAbort0(_engine); + ThrowIfError(result, "FwpmTransactionAbort0"); + } + + public IReadOnlyList ApplyDefaultBlock(SecurityIdentifier appContainerSid, IReadOnlyList? allowedEndpoints = null) + { + if (_subLayerKey is not null) throw new InvalidOperationException("WFP policy is already applied in this session."); + byte[] sidBytes = new byte[appContainerSid.BinaryLength]; + appContainerSid.GetBinaryForm(sidBytes, 0); + uint result = PInvoke.FwpmTransactionBegin0(_engine, 0); + ThrowIfError(result, "FwpmTransactionBegin0"); + bool committed = false; + try + { + Guid subLayerKey = Guid.NewGuid(); + fixed (char* subLayerName = "Agent Sandbox MCP task sublayer") + { + FWPM_SUBLAYER0 subLayer = default; + subLayer.subLayerKey = subLayerKey; + subLayer.displayData.name = new PWSTR(subLayerName); + subLayer.weight = 0x7FFF; + ThrowIfError(PInvoke.FwpmSubLayerAdd0(_engine, in subLayer, default), "FwpmSubLayerAdd0"); + } + + fixed (byte* sidPointer = sidBytes) + { + foreach (NetworkEndpoint endpoint in allowedEndpoints ?? []) _filterIds.Add(AddPackagePermit(endpoint, subLayerKey, (SID*)sidPointer)); + _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv4 default block")); + _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv6 default block")); + } + ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0"); + committed = true; + _subLayerKey = subLayerKey; + return _filterIds.ToArray(); + } + finally + { + if (!committed) + { + _filterIds.Clear(); + _ = PInvoke.FwpmTransactionAbort0(_engine); + } + } + } + + public void RemovePolicy() + { + if (_subLayerKey is null) return; + ThrowIfError(PInvoke.FwpmTransactionBegin0(_engine, 0), "FwpmTransactionBegin0(remove)"); + bool committed = false; + try + { + foreach (ulong filterId in _filterIds.AsEnumerable().Reverse()) ThrowIfError(PInvoke.FwpmFilterDeleteById0(_engine, filterId), "FwpmFilterDeleteById0"); + Guid subLayerKey = _subLayerKey.Value; + ThrowIfError(PInvoke.FwpmSubLayerDeleteByKey0(_engine, in subLayerKey), "FwpmSubLayerDeleteByKey0"); + ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0(remove)"); + committed = true; + _filterIds.Clear(); + _subLayerKey = null; + } + finally + { + if (!committed) _ = PInvoke.FwpmTransactionAbort0(_engine); + } + } + + public void Dispose() + { + _engine.Dispose(); + } + + private ulong AddPackageBlock(Guid layerKey, Guid subLayerKey, SID* sid, string displayName) + { + FWPM_FILTER_CONDITION0 condition = default; + condition.fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID; + condition.matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + condition.conditionValue.type = FWP_DATA_TYPE.FWP_SID; + condition.conditionValue.sid = sid; + fixed (char* name = displayName) + { + FWPM_FILTER0 filter = default; + filter.filterKey = Guid.NewGuid(); + filter.displayData.name = new PWSTR(name); + filter.layerKey = layerKey; + filter.subLayerKey = subLayerKey; + filter.weight.type = FWP_DATA_TYPE.FWP_UINT8; + filter.weight.uint8 = 8; + filter.numFilterConditions = 1; + filter.filterCondition = &condition; + filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_BLOCK; + ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0"); + return id; + } + } + + private ulong AddPackagePermit(NetworkEndpoint endpoint, Guid subLayerKey, SID* sid) + { + FWPM_FILTER_CONDITION0* conditions = stackalloc FWPM_FILTER_CONDITION0[3]; + conditions[0].fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID; + conditions[0].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + conditions[0].conditionValue.type = FWP_DATA_TYPE.FWP_SID; + conditions[0].conditionValue.sid = sid; + conditions[1].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS; + conditions[1].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + Guid layer; + FWP_BYTE_ARRAY16 ipv6 = default; + if (endpoint.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4; + conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_UINT32; + // FWP_UINT32 uses host-order numeric values. For 127.0.0.1 the + // numeric value is 0x7F000001; parse the network-order octets into + // that value rather than reinterpreting their in-memory byte order. + conditions[1].conditionValue.uint32 = BinaryPrimitives.ReadUInt32BigEndian(endpoint.Address.GetAddressBytes()); + } + else + { + layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6; + ipv6.byteArray16 = endpoint.Address.GetAddressBytes(); + conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_BYTE_ARRAY16_TYPE; + conditions[1].conditionValue.byteArray16 = &ipv6; + } + conditions[2].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT; + conditions[2].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + conditions[2].conditionValue.type = FWP_DATA_TYPE.FWP_UINT16; + conditions[2].conditionValue.uint16 = endpoint.Port; + string displayName = $"Agent Sandbox MCP allow {endpoint.Address}:{endpoint.Port}"; + fixed (char* name = displayName) + { + FWPM_FILTER0 filter = default; + filter.filterKey = Guid.NewGuid(); + filter.displayData.name = new PWSTR(name); + filter.layerKey = layer; + filter.subLayerKey = subLayerKey; + filter.weight.type = FWP_DATA_TYPE.FWP_UINT8; + filter.weight.uint8 = 15; + filter.numFilterConditions = 3; + filter.filterCondition = conditions; + // A plain permit is a soft permit and can be overridden by a block + // from another sub-layer (for example Windows Firewall). The exact + // task allowance must be a hard permit; our higher-weight match in + // this sub-layer still prevents the task default-block from running. + filter.flags = FwpmFilterFlagClearActionRight; + filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_PERMIT; + ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0(permit)"); + return id; + } + } + + private static void ThrowIfError(uint result, string operation) + { + if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with WFP/Win32 error 0x{result:X8}."); + } +} diff --git a/src/Agent.Windows/Recovery/RecoveryManager.cs b/src/Agent.Windows/Recovery/RecoveryManager.cs new file mode 100644 index 0000000..df69a5e --- /dev/null +++ b/src/Agent.Windows/Recovery/RecoveryManager.cs @@ -0,0 +1,114 @@ +using System.Security.AccessControl; +using System.Security.Cryptography; +using Agent.Windows.Security; +using Agent.Windows.Network; + +namespace Agent.Windows.Recovery; + +public sealed record RecoveryResult(int JournalsRecovered, int ResourcesRecovered, IReadOnlyList Errors) +{ + public bool IsClean => Errors.Count == 0; +} + +public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink audit) +{ + public RecoveryResult RecoverAll() + { + if (!Directory.Exists(journalDirectory)) return new(0, 0, []); + int journals = 0; + int resources = 0; + var errors = new List(); + foreach (string path in Directory.EnumerateFiles(journalDirectory, "*.journal.json").Order(StringComparer.Ordinal)) + { + journals++; + try + { + TaskResourceJournal journal = TaskResourceJournal.Open(path); + foreach (JournalEntry entry in journal.Entries.Reverse()) + { + if (entry.State == JournalEntryState.Cleaned) continue; + if (entry.State == JournalEntryState.Applied) RecoverEntry(journal.TaskId, entry); + CleanupPrepared(entry); + journal.MarkCleaned(entry.Id); + resources++; + } + journal.CompleteAndDelete(); + audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.complete", "clean", new Dictionary())); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) + { + errors.Add($"{path}: {ex.Message}"); + } + } + return new(journals, resources, errors); + } + + private static void RecoverEntry(Guid taskId, JournalEntry entry) + { + switch (entry.Kind) + { + case "file.replace": RecoverFile(entry); break; + case "file.delete": RecoverDeletedFile(entry); break; + case "acl": RecoverAcl(entry); break; + case "wfp.dynamic": break; // BFE removes every object when the originating dynamic session ends. + case "network.loopback_exemption": RecoverLoopback(entry); break; + default: throw new InvalidDataException($"Unknown journal resource kind '{entry.Kind}' for task '{taskId}'."); + } + } + + private static void RecoverLoopback(JournalEntry entry) + { + if (!bool.Parse(entry.Data["originally_present"])) LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target)); + } + + private static void RecoverFile(JournalEntry entry) + { + string replacement = entry.Data["replacement_sha256"]; + if (!File.Exists(entry.Target) || !Hash(entry.Target).Equals(replacement, StringComparison.Ordinal)) + throw new InvalidOperationException($"File '{entry.Target}' changed after crash; automatic rollback refused."); + if (bool.Parse(entry.Data["existed"])) + { + byte[] original = entry.Data["backup_path"].Length > 0 ? File.ReadAllBytes(entry.Data["backup_path"]) : Convert.FromBase64String(entry.Data["original_base64"]); + if (!Convert.ToHexString(SHA256.HashData(original)).Equals(entry.Data["original_sha256"], StringComparison.Ordinal)) throw new InvalidDataException("Recovery backup hash mismatch."); + string temporary = entry.Target + ".recovery.tmp"; + File.WriteAllBytes(temporary, original); + File.Move(temporary, entry.Target, overwrite: true); + } + else File.Delete(entry.Target); + } + + private static void RecoverAcl(JournalEntry entry) + { + byte[] descriptor = Convert.FromBase64String(entry.Data["security_descriptor"]); + FileSystemSecurity security = Directory.Exists(entry.Target) ? new DirectorySecurity() : new FileSecurity(); + security.SetSecurityDescriptorBinaryForm(descriptor, AccessControlSections.Access); + if (security is DirectorySecurity directory) FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(entry.Target), directory); + else FileSystemAclExtensions.SetAccessControl(new FileInfo(entry.Target), (FileSecurity)security); + } + + private static void RecoverDeletedFile(JournalEntry entry) + { + if (File.Exists(entry.Target)) throw new InvalidOperationException($"Deleted path '{entry.Target}' was recreated after crash; automatic recovery refused."); + byte[] original = entry.Data["backup_path"].Length > 0 ? File.ReadAllBytes(entry.Data["backup_path"]) : Convert.FromBase64String(entry.Data["original_base64"]); + if (!Convert.ToHexString(SHA256.HashData(original)).Equals(entry.Data["original_sha256"], StringComparison.Ordinal)) throw new InvalidDataException("Delete recovery backup hash mismatch."); + File.WriteAllBytes(entry.Target, original); + } + + private static void CleanupPrepared(JournalEntry entry) + { + if (entry.Kind == "network.loopback_exemption" && !bool.Parse(entry.Data["originally_present"])) + LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target)); + if (entry.Data.TryGetValue("temporary_path", out string? temporary) && temporary.Length > 0) File.Delete(temporary); + if (entry.Data.TryGetValue("backup_path", out string? backup) && backup.Length > 0) + { + File.Delete(backup); + string? parent = System.IO.Path.GetDirectoryName(backup); + if (parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent); + } + } + private static string Hash(string target) + { + using FileStream stream = File.OpenRead(target); + return Convert.ToHexString(SHA256.HashData(stream)); + } +} diff --git a/src/Agent.Windows/Recovery/TaskResourceJournal.cs b/src/Agent.Windows/Recovery/TaskResourceJournal.cs new file mode 100644 index 0000000..b638041 --- /dev/null +++ b/src/Agent.Windows/Recovery/TaskResourceJournal.cs @@ -0,0 +1,108 @@ +using System.Text.Json; + +namespace Agent.Windows.Recovery; + +public enum JournalEntryState { Prepared, Applied, Cleaned } + +public sealed class JournalEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Kind { get; set; } = string.Empty; + public string Target { get; set; } = string.Empty; + public JournalEntryState State { get; set; } + public Dictionary Data { get; set; } = new(StringComparer.Ordinal); +} + +public sealed class JournalDocument +{ + public int SchemaVersion { get; set; } = 1; + public Guid TaskId { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public List Entries { get; set; } = []; +} + +public sealed class TaskResourceJournal +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + private readonly object _sync = new(); + private readonly JournalDocument _document; + + private TaskResourceJournal(string path, JournalDocument document) { Path = path; _document = document; } + public string Path { get; } + public Guid TaskId => _document.TaskId; + public IReadOnlyList Entries { get { lock (_sync) return _document.Entries.Select(Clone).ToArray(); } } + + public static TaskResourceJournal Create(string journalDirectory, Guid taskId) + { + Directory.CreateDirectory(journalDirectory); + string path = System.IO.Path.Combine(journalDirectory, taskId.ToString("N") + ".journal.json"); + if (File.Exists(path)) throw new InvalidOperationException($"Journal already exists for task '{taskId}'."); + var journal = new TaskResourceJournal(path, new JournalDocument { TaskId = taskId }); + journal.Persist(); + return journal; + } + + public static TaskResourceJournal Open(string path) + { + JournalDocument document = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) + ?? throw new InvalidDataException($"Journal '{path}' is empty."); + if (document.SchemaVersion != 1 || document.TaskId == Guid.Empty) throw new InvalidDataException($"Journal '{path}' is invalid."); + return new(path, document); + } + + public Guid Prepare(string kind, string target, IReadOnlyDictionary data) + { + lock (_sync) + { + var entry = new JournalEntry { Kind = kind, Target = target, State = JournalEntryState.Prepared, Data = new(data, StringComparer.Ordinal) }; + _document.Entries.Add(entry); + Persist(); + return entry.Id; + } + } + + public void MarkApplied(Guid entryId, IReadOnlyDictionary? additionalData = null) + { + lock (_sync) + { + JournalEntry entry = Get(entryId); + if (entry.State != JournalEntryState.Prepared) throw new InvalidOperationException("Only prepared resources can be marked applied."); + if (additionalData is not null) foreach ((string key, string value) in additionalData) entry.Data[key] = value; + entry.State = JournalEntryState.Applied; + Persist(); + } + } + + public void MarkCleaned(Guid entryId) + { + lock (_sync) + { + JournalEntry entry = Get(entryId); + if (entry.State == JournalEntryState.Cleaned) return; + entry.State = JournalEntryState.Cleaned; + Persist(); + } + } + + public void CompleteAndDelete() + { + lock (_sync) + { + if (_document.Entries.Any(entry => entry.State != JournalEntryState.Cleaned)) throw new InvalidOperationException("Journal still contains live resources."); + File.Delete(Path); + } + } + + private JournalEntry Get(Guid id) => _document.Entries.SingleOrDefault(entry => entry.Id == id) ?? throw new KeyNotFoundException($"Journal entry '{id}' not found."); + private void Persist() + { + string temporary = Path + ".tmp"; + using (FileStream stream = new(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + JsonSerializer.Serialize(stream, _document, JsonOptions); + stream.Flush(flushToDisk: true); + } + File.Move(temporary, Path, overwrite: true); + } + private static JournalEntry Clone(JournalEntry source) => new() { Id = source.Id, Kind = source.Kind, Target = source.Target, State = source.State, Data = new(source.Data, StringComparer.Ordinal) }; +} diff --git a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs new file mode 100644 index 0000000..52963a0 --- /dev/null +++ b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs @@ -0,0 +1,141 @@ +using System.Security.Cryptography; +using System.Text; +using Agent.Windows.Security; + +namespace Agent.Windows.Recovery; + +public sealed class TransactionalFileEditor(TaskResourceJournal journal, string backupRoot, long memoryBackupLimitBytes = 16 * 1024 * 1024) +{ + public FileEditTransaction Replace(string path, string content, string? expectedSha256 = null) => Replace(path, Encoding.UTF8.GetBytes(content), expectedSha256); + + public FileEditTransaction Replace(string path, ReadOnlySpan content, string? expectedSha256 = null) + { + string canonical = CanonicalPath.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)); + if (expectedSha256 is not null && !originalHash.Equals(NormalizeHash(expectedSha256), StringComparison.Ordinal)) + throw new SandboxPolicyException("file.concurrent_change", "File hash does not match expected_sha256."); + + string backupPath = string.Empty; + string inline = string.Empty; + if (original is not null && original.LongLength > memoryBackupLimitBytes) + { + string directory = System.IO.Path.Combine(backupRoot, journal.TaskId.ToString("N")); + Directory.CreateDirectory(directory); + backupPath = System.IO.Path.Combine(directory, Guid.NewGuid().ToString("N") + ".backup"); + File.WriteAllBytes(backupPath, original); + } + else if (original is not null) inline = Convert.ToBase64String(original); + + string temporary = canonical + ".agent-mcp-" + Guid.NewGuid().ToString("N") + ".tmp"; + var data = new Dictionary + { + ["existed"] = existed.ToString(), ["original_sha256"] = originalHash, ["original_base64"] = inline, + ["backup_path"] = backupPath, ["temporary_path"] = temporary + }; + Guid entryId = journal.Prepare("file.replace", canonical, data); + File.WriteAllBytes(temporary, content); + string replacementHash = Convert.ToHexString(SHA256.HashData(content)); + File.Move(temporary, canonical, overwrite: true); + journal.MarkApplied(entryId, new Dictionary { ["replacement_sha256"] = replacementHash }); + return new(journal, entryId, canonical, originalHash, replacementHash, backupPath, inline, existed); + } + + public FileDeleteTransaction Delete(string path, string? expectedSha256 = null) + { + string canonical = CanonicalPath.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)) + throw new SandboxPolicyException("file.concurrent_change", "File hash does not match expected_sha256."); + string backupPath = string.Empty; + string inline = string.Empty; + if (original.LongLength > memoryBackupLimitBytes) + { + string directory = System.IO.Path.Combine(backupRoot, journal.TaskId.ToString("N")); + Directory.CreateDirectory(directory); + backupPath = System.IO.Path.Combine(directory, Guid.NewGuid().ToString("N") + ".backup"); + File.WriteAllBytes(backupPath, original); + } + else inline = Convert.ToBase64String(original); + Guid entryId = journal.Prepare("file.delete", canonical, new Dictionary + { + ["original_sha256"] = originalHash, ["original_base64"] = inline, ["backup_path"] = backupPath + }); + File.Delete(canonical); + journal.MarkApplied(entryId); + return new(journal, entryId, canonical, originalHash, backupPath, inline); + } + + private static string NormalizeHash(string hash) => hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant(); +} + +public sealed class FileDeleteTransaction(TaskResourceJournal journal, Guid entryId, string path, string originalHash, string backupPath, string originalBase64) +{ + private bool _finished; + public void Commit() { if (_finished) return; Cleanup(); journal.MarkCleaned(entryId); _finished = true; } + public void Rollback() + { + if (_finished) return; + if (File.Exists(path)) throw new InvalidOperationException("Rollback refused because the deleted path was recreated."); + byte[] original = backupPath.Length > 0 ? File.ReadAllBytes(backupPath) : Convert.FromBase64String(originalBase64); + if (!Convert.ToHexString(SHA256.HashData(original)).Equals(originalHash, StringComparison.Ordinal)) throw new InvalidDataException("Delete backup hash verification failed."); + File.WriteAllBytes(path, original); + Cleanup(); journal.MarkCleaned(entryId); _finished = true; + } + private void Cleanup() + { + if (backupPath.Length == 0) return; + File.Delete(backupPath); + string? parent = System.IO.Path.GetDirectoryName(backupPath); + if (parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent); + } +} + +public sealed class FileEditTransaction( + TaskResourceJournal journal, Guid entryId, string path, string originalHash, string replacementHash, + string backupPath, string originalBase64, bool originallyExisted) +{ + private bool _finished; + + public void Commit() + { + if (_finished) return; + DeleteBackup(); + journal.MarkCleaned(entryId); + _finished = true; + } + + public void Rollback() + { + if (_finished) return; + if (!File.Exists(path) || !Hash(path).Equals(replacementHash, StringComparison.Ordinal)) + throw new InvalidOperationException("Rollback refused because the replacement file changed after the transaction."); + if (originallyExisted) + { + byte[] original = backupPath.Length > 0 ? File.ReadAllBytes(backupPath) : Convert.FromBase64String(originalBase64); + if (!Convert.ToHexString(SHA256.HashData(original)).Equals(originalHash, StringComparison.Ordinal)) throw new InvalidDataException("Backup hash verification failed."); + string temporary = path + ".rollback-" + Guid.NewGuid().ToString("N") + ".tmp"; + File.WriteAllBytes(temporary, original); + File.Move(temporary, path, overwrite: true); + } + else File.Delete(path); + DeleteBackup(); + journal.MarkCleaned(entryId); + _finished = true; + } + + private void DeleteBackup() + { + if (backupPath.Length == 0) return; + File.Delete(backupPath); + string? parent = System.IO.Path.GetDirectoryName(backupPath); + if (parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent); + } + private static string Hash(string target) + { + using FileStream stream = File.OpenRead(target); + return Convert.ToHexString(SHA256.HashData(stream)); + } +} diff --git a/src/Agent.Windows/Security/AppContainerProfile.cs b/src/Agent.Windows/Security/AppContainerProfile.cs new file mode 100644 index 0000000..47aa64e --- /dev/null +++ b/src/Agent.Windows/Security/AppContainerProfile.cs @@ -0,0 +1,41 @@ +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace Agent.Windows.Security; + +public sealed partial class AppContainerProfile +{ + public const string FixedName = "AgentSandboxMcp.V1"; + private const int AlreadyExists = unchecked((int)0x800700B7); + + private AppContainerProfile(string name, SecurityIdentifier sid) { Name = name; Sid = sid; } + public string Name { get; } + public SecurityIdentifier Sid { get; } + + public static AppContainerProfile EnsureFixed() + { + int result = Native.CreateAppContainerProfile(FixedName, "Agent Sandbox MCP v1", "Fixed sandbox profile for Agent Sandbox MCP", 0, 0, out nint sidPointer); + if (result == AlreadyExists) result = Native.DeriveAppContainerSidFromAppContainerName(FixedName, out sidPointer); + if (result < 0) Marshal.ThrowExceptionForHR(result); + try + { + return new(FixedName, new SecurityIdentifier(sidPointer)); + } + finally + { + if (sidPointer != 0) _ = Native.FreeSid(sidPointer); + } + } + + private static partial class Native + { + [LibraryImport("userenv.dll", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int CreateAppContainerProfile(string name, string displayName, string description, nint capabilities, uint capabilityCount, out nint sid); + + [LibraryImport("userenv.dll", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int DeriveAppContainerSidFromAppContainerName(string name, out nint sid); + + [LibraryImport("advapi32.dll")] + internal static partial nint FreeSid(nint sid); + } +} diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs new file mode 100644 index 0000000..3d20bfa --- /dev/null +++ b/src/Agent.Windows/Security/ExecutablePolicy.cs @@ -0,0 +1,51 @@ +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 sealed class ExecutablePolicy(IEnumerable approvals, bool allowShell) +{ + private readonly IReadOnlyDictionary _approvals = approvals.ToDictionary( + approval => CanonicalPath.Resolve(approval.Path), approval => NormalizeHash(approval.Sha256), StringComparer.OrdinalIgnoreCase); + + public VerifiedExecutable Verify(string executable) + { + string canonical = CanonicalPath.Resolve(executable); + if (!allowShell && IsShell(canonical)) throw new SandboxPolicyException("process.shell_denied", "Shell execution is disabled."); + if (!_approvals.TryGetValue(canonical, out string? approvedHash)) throw new SandboxPolicyException("process.path_denied", "Executable path is not approved."); + 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."); + return new(canonical, actualHash); + } + + public static string? ManagedEntryPoint(string executable, IReadOnlyList arguments) + { + if (!Path.GetFileName(executable).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 arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? arguments[0] : null; + } + + public static string ComputeSha256(string executable) + { + using FileStream stream = new(CanonicalPath.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 string NormalizeHash(string hash) + { + string normalized = hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant(); + if (normalized.Length != 64 || !normalized.All(Uri.IsHexDigit)) throw new ArgumentException("SHA-256 approval must contain 64 hexadecimal characters.", nameof(hash)); + return normalized; + } +} diff --git a/src/Agent.Windows/Security/PathPolicy.cs b/src/Agent.Windows/Security/PathPolicy.cs new file mode 100644 index 0000000..32a949c --- /dev/null +++ b/src/Agent.Windows/Security/PathPolicy.cs @@ -0,0 +1,52 @@ +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 class PathPolicy(IEnumerable entries) +{ + private readonly IReadOnlyList _entries = entries.Select(entry => entry with { Path = CanonicalPath.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); + 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}."); + } + + private static bool IsAtOrBelow(string candidate, string root) => + candidate.Equals(root, StringComparison.OrdinalIgnoreCase) || + candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); +} + +public static class CanonicalPath +{ + public static string Resolve(string path, bool requireExisting = true) + { + if (string.IsNullOrWhiteSpace(path)) throw new SandboxPolicyException("path.invalid", "Path is required."); + string fullPath = Path.GetFullPath(path); + string? root = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(root)) throw new SandboxPolicyException("path.invalid", "Path has no filesystem root."); + string current = root; + 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) + throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: '{current}'."); + } + if (requireExisting && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Approved path does not exist.", fullPath); + return Path.TrimEndingDirectorySeparator(fullPath); + } +} + +public sealed class SandboxPolicyException(string code, string message) : UnauthorizedAccessException(message) +{ + public string Code { get; } = code; +} diff --git a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs new file mode 100644 index 0000000..0786150 --- /dev/null +++ b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs @@ -0,0 +1,255 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; +using Microsoft.Win32.SafeHandles; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Security; +using Windows.Win32.System.JobObjects; +using Windows.Win32.System.Threading; + +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) +{ + private const uint ProcessCreationChildProcessRestricted = 0x1; + private readonly SandboxJobLimits _jobLimits = jobLimits ?? new(); + + public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList arguments, string workingDirectory, TimeSpan timeout, string? standardInput = null) + { + VerifiedExecutable verified; + VerifiedExecutable? managedEntry = null; + try + { + verified = executablePolicy.Verify(executable); + string? managedEntryPath = ExecutablePolicy.ManagedEntryPoint(verified.CanonicalPath, arguments); + if (managedEntryPath is not null) managedEntry = executablePolicy.Verify(managedEntryPath); + } + catch (SandboxPolicyException ex) + { + audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary { ["code"] = ex.Code, ["executable"] = executable })); + throw; + } + + string cwd = CanonicalPath.Resolve(workingDirectory); + using FileStream stdin = CaptureFile(); + using FileStream stdout = CaptureFile(); + using FileStream stderr = CaptureFile(); + if (!string.IsNullOrEmpty(standardInput)) + { + byte[] input = Encoding.UTF8.GetBytes(standardInput); + stdin.Write(input); + stdin.Flush(true); + } + stdin.Position = 0; + MakeInheritable(stdin.SafeFileHandle); + MakeInheritable(stdout.SafeFileHandle); + MakeInheritable(stderr.SafeFileHandle); + using SafeFileHandle job = PInvoke.CreateJobObject(null, null); + if (job.IsInvalid) throw LastError("CreateJobObject"); + try + { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = default; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | + JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_JOB_MEMORY; + limits.BasicLimitInformation.ActiveProcessLimit = _jobLimits.MaxProcesses; + limits.JobMemoryLimit = (nuint)_jobLimits.MaxJobMemoryBytes; + if (!PInvoke.SetInformationJobObject(new HANDLE(job.DangerousGetHandle()), JOBOBJECTINFOCLASS.JobObjectExtendedLimitInformation, &limits, (uint)sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION))) + throw LastError("SetInformationJobObject"); + + nuint attributeSize = 0; + uint attributeCount = appContainerSid is null ? 2u : 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; + 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()), + new(stdout.SafeFileHandle.DangerousGetHandle()), + new(stderr.SafeFileHandle.DangerousGetHandle()) + ]; + fixed (HANDLE* inheritedHandlePointer = inheritedHandles) + { + if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + inheritedHandlePointer, (nuint)(sizeof(HANDLE) * inheritedHandles.Length), null, null)) + throw LastError("UpdateProcThreadAttribute(HandleList)"); + } + SecurityCapabilities securityCapabilities = default; + if (appContainerSid is not null) + { + byte[] sidBytes = new byte[appContainerSid.BinaryLength]; + appContainerSid.GetBinaryForm(sidBytes, 0); + 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; + securityCapabilities.Capabilities = (nint)capabilities; + securityCapabilities.CapabilityCount = 2; + if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, + &securityCapabilities, (nuint)sizeof(SecurityCapabilities), null, null)) throw LastError("UpdateProcThreadAttribute(SecurityCapabilities)"); + } + STARTUPINFOEXW startup = default; + startup.StartupInfo.cb = (uint)sizeof(STARTUPINFOEXW); + startup.StartupInfo.dwFlags = STARTUPINFOW_FLAGS.STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = inheritedHandles[0]; + startup.StartupInfo.hStdOutput = inheritedHandles[1]; + startup.StartupInfo.hStdError = inheritedHandles[2]; + startup.lpAttributeList = attributes; + PROCESS_INFORMATION process = default; + string commandLine = BuildCommandLine(verified.CanonicalPath, arguments); + Span 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"); + 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) || + !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."); + } + } + catch (SandboxPolicyException ex) + { + audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary + { + ["code"] = ex.Code, ["executable"] = executable, ["stage"] = "post_create_pre_resume" + })); + throw; + } + if (PInvoke.ResumeThread(process.hThread) == uint.MaxValue) throw LastError("ResumeThread"); + var processAudit = new Dictionary + { + ["path"] = verified.CanonicalPath, ["sha256"] = verified.Sha256, ["child_process_policy"] = "restricted", + ["app_container_sid"] = appContainerSid?.Value ?? "none", ["network_capabilities"] = appContainerSid is null ? "none" : "internetClient,privateNetworkClientServer", + ["pid"] = process.dwProcessId.ToString() + }; + if (managedEntry is not null) + { + processAudit["managed_entry_path"] = managedEntry.CanonicalPath; + processAudit["managed_entry_sha256"] = managedEntry.Sha256; + } + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit)); + WAIT_EVENT wait = PInvoke.WaitForSingleObject(process.hProcess, checked((uint)Math.Clamp(timeout.TotalMilliseconds, 1, uint.MaxValue - 1))); + if (wait == WAIT_EVENT.WAIT_TIMEOUT) + { + _ = PInvoke.TerminateJobObject(job, 1460); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.timeout", "terminated", new Dictionary { ["pid"] = process.dwProcessId.ToString() })); + throw new TimeoutException("Sandbox process exceeded its timeout."); + } + if (!PInvoke.GetExitCodeProcess(processView, out uint exitCode)) throw LastError("GetExitCodeProcess"); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.exit", "observed", new Dictionary { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() })); + if (exitCode != 0) + { + audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "observed", new Dictionary + { + ["code"] = "restricted_process_failure", ["pid"] = process.dwProcessId.ToString(), + ["child_process_policy"] = "restricted", ["exit_code"] = exitCode.ToString() + })); + } + return new(process.dwProcessId, exitCode, ReadCapture(stdout), ReadCapture(stderr)); + } + finally + { + PInvoke.CloseHandle(process.hThread); + PInvoke.CloseHandle(process.hProcess); + } + } + finally + { + if (attributes.Value is not null) PInvoke.DeleteProcThreadAttributeList(attributes); + NativeMemory.Free(sidBuffer); + NativeMemory.Free(internetClientSidBuffer); + NativeMemory.Free(privateNetworkSidBuffer); + NativeMemory.Free(attributes.Value); + } + } + finally { } + } + + private static Win32Exception LastError(string operation) + { + int code = Marshal.GetLastWin32Error(); + return new(code, $"{operation} failed with Win32 error {code}."); + } + private static FileStream CaptureFile() + { + string path = Path.Combine(Path.GetTempPath(), "agent-mcp-process-" + Guid.NewGuid().ToString("N") + ".tmp"); + return new FileStream(path, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete, 4096, + FileOptions.DeleteOnClose | FileOptions.SequentialScan); + } + private static void* AllocateSid(SecurityIdentifier sid) + { + byte[] bytes = new byte[sid.BinaryLength]; + sid.GetBinaryForm(bytes, 0); + void* buffer = NativeMemory.Alloc((nuint)bytes.Length); + Marshal.Copy(bytes, 0, (nint)buffer, bytes.Length); + return buffer; + } + private static void MakeInheritable(SafeFileHandle handle) + { + if (!PInvoke.SetHandleInformation(handle, (uint)HANDLE_FLAGS.HANDLE_FLAG_INHERIT, HANDLE_FLAGS.HANDLE_FLAG_INHERIT)) + throw LastError("SetHandleInformation"); + } + private static string ReadCapture(FileStream stream) + { + const int maxCharacters = 1024 * 1024; + stream.Position = 0; + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true); + char[] buffer = new char[maxCharacters + 1]; + int count = reader.ReadBlock(buffer, 0, buffer.Length); + return count > maxCharacters ? new string(buffer, 0, maxCharacters) + "\n[output truncated]" : new string(buffer, 0, count); + } + private static string BuildCommandLine(string executable, IReadOnlyList arguments) => string.Join(' ', [Quote(executable), .. arguments.Select(Quote)]); + private static string Quote(string value) + { + if (value.Length > 0 && !value.Any(static character => char.IsWhiteSpace(character) || character == '"')) return value; + var result = new System.Text.StringBuilder().Append('"'); + int slashes = 0; + foreach (char character in value) + { + if (character == '\\') { slashes++; continue; } + if (character == '"') result.Append('\\', (slashes * 2) + 1).Append('"'); + else result.Append('\\', slashes).Append(character); + slashes = 0; + } + return result.Append('\\', slashes * 2).Append('"').ToString(); + } + + [StructLayout(LayoutKind.Sequential)] + private struct SecurityCapabilities + { + public nint AppContainerSid; + public nint Capabilities; + public uint CapabilityCount; + public uint Reserved; + } +} diff --git a/src/Agent.Windows/Security/SecurityAudit.cs b/src/Agent.Windows/Security/SecurityAudit.cs new file mode 100644 index 0000000..8858c2b --- /dev/null +++ b/src/Agent.Windows/Security/SecurityAudit.cs @@ -0,0 +1,29 @@ +using System.Text.Json; + +namespace Agent.Windows.Security; + +public sealed record SecurityAuditEvent(DateTimeOffset Timestamp, Guid TaskId, string Event, string Outcome, IReadOnlyDictionary Data); + +public interface ISecurityAuditSink +{ + void Write(SecurityAuditEvent auditEvent); +} + +public sealed class JsonLinesSecurityAuditSink(string path) : ISecurityAuditSink +{ + private readonly object _sync = new(); + public void Write(SecurityAuditEvent auditEvent) + { + string? directory = Path.GetDirectoryName(Path.GetFullPath(path)); + if (directory is not null) Directory.CreateDirectory(directory); + string line = JsonSerializer.Serialize(auditEvent); + lock (_sync) File.AppendAllText(path, line + Environment.NewLine); + } +} + +public sealed class MemorySecurityAuditSink : ISecurityAuditSink +{ + private readonly List _events = []; + public IReadOnlyList Events => _events; + public void Write(SecurityAuditEvent auditEvent) => _events.Add(auditEvent); +} diff --git a/src/Agent.Windows/Security/TaskAclManager.cs b/src/Agent.Windows/Security/TaskAclManager.cs new file mode 100644 index 0000000..f01e859 --- /dev/null +++ b/src/Agent.Windows/Security/TaskAclManager.cs @@ -0,0 +1,76 @@ +using System.Security.AccessControl; +using System.Security.Principal; +using Agent.Windows.Recovery; + +namespace Agent.Windows.Security; + +public sealed record AclSnapshot(string Path, byte[] SecurityDescriptor, Guid? JournalEntryId = null); + +public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurityAuditSink audit, TaskResourceJournal? journal = null) +{ + public AclSnapshot Grant(Guid taskId, string path, FileCapability capabilities) + { + string canonical = CanonicalPath.Resolve(path); + FileSystemSecurity security = ReadSecurity(canonical); + byte[] original = security.GetSecurityDescriptorBinaryForm(); + Guid? journalEntry = journal?.Prepare("acl", canonical, new Dictionary + { + ["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); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.apply", "applied", new Dictionary { ["path"] = canonical, ["sid"] = appContainerSid.Value, ["rights"] = rights.ToString() })); + return new(canonical, original, journalEntry); + } + + public void Restore(Guid taskId, AclSnapshot snapshot) + { + FileSystemSecurity security = Directory.Exists(snapshot.Path) ? new DirectorySecurity() : new FileSecurity(); + security.SetSecurityDescriptorBinaryForm(snapshot.SecurityDescriptor, AccessControlSections.Access); + WriteSecurity(snapshot.Path, security); + if (snapshot.JournalEntryId is not null) journal!.MarkCleaned(snapshot.JournalEntryId.Value); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.restore", "restored", new Dictionary { ["path"] = snapshot.Path })); + } + + public void NormalizeNewEntry(Guid taskId, string path) + { + string canonical = CanonicalPath.Resolve(path); + FileSystemSecurity security = ReadSecurity(canonical); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + foreach (FileSystemAccessRule rule in security.GetAccessRules(includeExplicit: true, includeInherited: false, typeof(SecurityIdentifier)) + .OfType() + .Where(rule => rule.IdentityReference == appContainerSid) + .ToArray()) + security.RemoveAccessRuleSpecific(rule); + security.SetAccessRuleProtection(isProtected: false, preserveInheritance: false); + WriteSecurity(canonical, security); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.normalize", "normalized", new Dictionary { ["path"] = canonical })); + } + + private static FileSystemSecurity ReadSecurity(string path) => Directory.Exists(path) + ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access) + : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access); + + private static void WriteSecurity(string path, FileSystemSecurity security) + { + if (security is DirectorySecurity directorySecurity) FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(path), directorySecurity); + else FileSystemAclExtensions.SetAccessControl(new FileInfo(path), (FileSecurity)security); + } + + private static FileSystemRights ToRights(FileCapability capabilities) + { + FileSystemRights rights = 0; + if ((capabilities & FileCapability.Read) != 0) rights |= FileSystemRights.ReadAndExecute | FileSystemRights.ListDirectory; + if ((capabilities & FileCapability.Write) != 0) rights |= FileSystemRights.Write; + if ((capabilities & FileCapability.Execute) != 0) rights |= FileSystemRights.ReadAndExecute; + if ((capabilities & FileCapability.Delete) != 0) rights |= FileSystemRights.Delete | FileSystemRights.DeleteSubdirectoriesAndFiles; + return rights; + } +} diff --git a/src/Agent.Windows/Security/UserOnlyDirectory.cs b/src/Agent.Windows/Security/UserOnlyDirectory.cs new file mode 100644 index 0000000..a9ae5ae --- /dev/null +++ b/src/Agent.Windows/Security/UserOnlyDirectory.cs @@ -0,0 +1,20 @@ +using System.Security.AccessControl; +using System.Security.Principal; + +namespace Agent.Windows.Security; + +public static class UserOnlyDirectory +{ + public static void Ensure(string path) + { + Directory.CreateDirectory(path); + using WindowsIdentity identity = WindowsIdentity.GetCurrent(); + SecurityIdentifier user = identity.User ?? throw new InvalidOperationException("Current Windows identity has no user SID."); + var security = new DirectorySecurity(); + security.SetOwner(user); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule(user, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); + FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(path), security); + } +} diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs new file mode 100644 index 0000000..a21544b --- /dev/null +++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs @@ -0,0 +1,257 @@ +using System.Collections.Concurrent; +using System.IO.Enumeration; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Agent.Core.Tools; +using Agent.Windows.Network; +using Agent.Windows.Recovery; +using Agent.Windows.Security; + +namespace Agent.Windows.Tools; + +public sealed record WindowsSandboxSettings( + IReadOnlyList Files, + IReadOnlyList Executables, + bool AllowShell, + SandboxJobLimits JobLimits, + string NetworkMode, + IReadOnlyList<(string Host, IReadOnlyList Ports)> NetworkAllowances, + string JournalDirectory, + string BackupDirectory, + long MemoryBackupLimitBytes, + long WholeReadLimitBytes, + int SearchResultLimit, + int TailLineLimit); + +public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurityAuditSink audit, ITaskUpdateHandler? taskUpdates = null) : IInternalToolBackend +{ + private readonly ConcurrentDictionary _contexts = new(); + private readonly PathPolicy _paths = new(settings.Files); + private readonly ExecutablePolicy _executables = new(settings.Executables, settings.AllowShell); + private readonly ITaskUpdateHandler _taskUpdates = taskUpdates ?? new TaskGraphToolHandler(); + + public async Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) + { + 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); + var acl = new TaskAclManager(profile.Sid, audit, journal); + var context = new TaskContext(journal, acl); + _taskUpdates.BeginTask(taskId); + 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)))) + { + // 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. + if (File.Exists(path) && capabilities == FileCapability.Execute) continue; + context.Roots.Add(path); + foreach (string target in ExistingAclTargets(path)) context.Acls.Add(acl.Grant(taskId, target, capabilities)); + } + IReadOnlyList allowances = settings.NetworkMode == "allow" + ? await NetworkAllowanceSnapshot.ResolveAsync(settings.NetworkAllowances, cancellationToken).ConfigureAwait(false) + : []; + context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit); + if (!_contexts.TryAdd(taskId, context)) throw new InvalidOperationException("Task context already exists."); + } + catch + { + try { await CleanupAsync(taskId, context).ConfigureAwait(false); } + finally { _taskUpdates.EndTask(taskId); } + throw; + } + } + + public Task ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) + { + TaskContext context = Get(taskId); + return toolName switch + { + "file_search" => Task.FromResult(Search(arguments)), + "file_read" => Task.FromResult(Read(arguments)), + "file_edit" => Task.FromResult(Edit(context, arguments)), + "process_run" => Task.FromResult(RunProcess(taskId, arguments)), + "task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)), + _ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.") + }; + } + + public async Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) + { + if (!_contexts.TryRemove(taskId, out TaskContext? context)) return; + try { await CleanupAsync(taskId, context).ConfigureAwait(false); } + finally { _taskUpdates.EndTask(taskId); } + } + + private ToolExecutionResult Search(JsonElement arguments) + { + string root = arguments.GetProperty("root").GetString()!; + _paths.Demand(root, FileCapability.Read); + string canonical = CanonicalPath.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; + max = Math.Min(max, settings.SearchResultLimit); + if (mode == "tail") + { + int lines = arguments.TryGetProperty("tail_lines", out JsonElement lineElement) ? lineElement.GetInt32() : 100; + lines = Math.Min(lines, settings.TailLineLimit); + if (new FileInfo(canonical).Length > settings.WholeReadLimitBytes * 16) throw new IOException("Tail input is too large."); + string[] content = File.ReadLines(canonical).TakeLast(lines).ToArray(); + return Success(JsonSerializer.Serialize(content)); + } + IEnumerable files = File.Exists(canonical) ? [canonical] : Directory.EnumerateFiles(canonical, "*", new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + ReturnSpecialDirectories = false, + AttributesToSkip = FileAttributes.ReparsePoint + }); + IEnumerable results = mode switch + { + "glob" => files.Where(path => FileSystemName.MatchesSimpleExpression(query, Path.GetRelativePath(canonical, path), ignoreCase: true)), + "name" => files.Where(path => Path.GetFileName(path).Contains(query, StringComparison.OrdinalIgnoreCase)), + "text" => files.Where(path => IsTextMatch(path, text => text.Contains(query, StringComparison.Ordinal))), + "regex" => SearchRegex(files, query), + _ => throw new ToolValidationException($"Unsupported search mode '{mode}'.") + }; + return Success(JsonSerializer.Serialize(results.Take(max).Select(Path.GetFullPath).ToArray())); + } + + private ToolExecutionResult Read(JsonElement arguments) + { + string path = arguments.GetProperty("path").GetString()!; + _paths.Demand(path, FileCapability.Read); + string canonical = CanonicalPath.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 == "whole") + { + if (info.Length > settings.WholeReadLimitBytes) throw new IOException("Whole read exceeds configured limit; use range mode."); + return Success(File.ReadAllText(canonical)); + } + long start = arguments.TryGetProperty("start", out JsonElement startElement) ? startElement.GetInt64() : 0; + int length = arguments.GetProperty("length").GetInt32(); + if (length > settings.WholeReadLimitBytes) throw new IOException("Range length exceeds configured limit."); + using FileStream stream = File.OpenRead(canonical); + stream.Position = start; + byte[] buffer = new byte[Math.Min(length, checked((int)Math.Max(0, stream.Length - start)))]; + _ = stream.Read(buffer); + return Success(Encoding.UTF8.GetString(buffer)); + } + + private ToolExecutionResult Edit(TaskContext context, JsonElement arguments) + { + string path = arguments.GetProperty("path").GetString()!; + string operation = arguments.GetProperty("operation").GetString()!; + string? expected = arguments.TryGetProperty("expected_sha256", out JsonElement expectedElement) && expectedElement.ValueKind == JsonValueKind.String ? expectedElement.GetString() : null; + var editor = new TransactionalFileEditor(context.Journal, settings.BackupDirectory, settings.MemoryBackupLimitBytes); + if (operation == "delete") + { + _paths.Demand(path, FileCapability.Delete); + FileDeleteTransaction transaction = editor.Delete(path, expected); + transaction.Commit(); + } + else + { + _paths.Demand(path, FileCapability.Write); + string replacement = operation == "patch" + ? ApplyPatch(path, arguments.GetProperty("patch").GetString()!) + : arguments.GetProperty("content").GetString()!; + FileEditTransaction transaction = editor.Replace(path, replacement, expected); + transaction.Commit(); + } + audit.Write(new(DateTimeOffset.UtcNow, context.Journal.TaskId, "file." + operation, "completed", new Dictionary { ["path"] = Path.GetFullPath(path) })); + return Success($"{operation} completed for {Path.GetFullPath(path)}", externalEffect: true); + } + + private ToolExecutionResult RunProcess(Guid taskId, JsonElement arguments) + { + 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); + if (managedEntryPoint is not null) _paths.Demand(managedEntryPoint, FileCapability.Execute); + int seconds = arguments.TryGetProperty("timeout_seconds", out JsonElement timeout) ? timeout.GetInt32() : 300; + 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); + 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}"); + } + + private async Task CleanupAsync(Guid taskId, TaskContext context) + { + List failures = []; + try { context.Network?.Dispose(); } catch (Exception ex) { failures.Add(ex); } + foreach (AclSnapshot snapshot in context.Acls.AsEnumerable().Reverse()) try { context.Acl.Restore(taskId, snapshot); } catch (Exception ex) { failures.Add(ex); } + var existingSnapshots = context.Acls.Select(static snapshot => snapshot.Path).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (string entry in context.Roots.SelectMany(ExistingAclTargets).Distinct(StringComparer.OrdinalIgnoreCase) + .Where(entry => !existingSnapshots.Contains(entry)) + .OrderBy(static entry => entry.Count(character => character == Path.DirectorySeparatorChar))) + try { context.Acl.NormalizeNewEntry(taskId, entry); } catch (Exception ex) { failures.Add(ex); } + if (failures.Count == 0) context.Journal.CompleteAndDelete(); + await Task.CompletedTask; + if (failures.Count > 0) throw new AggregateException("Task resource cleanup failed.", failures); + } + + private TaskContext Get(Guid taskId) => _contexts.TryGetValue(taskId, out TaskContext? context) ? context : throw new InvalidOperationException("Task Resource scope is not active."); + private static ToolExecutionResult Success(string output, bool externalEffect = false) => new(true, output, externalEffect); + private static bool IsTextMatch(string path, Func predicate) + { + try { return new FileInfo(path).Length <= 1024 * 1024 && predicate(File.ReadAllText(path)); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DecoderFallbackException) { return false; } + } + private static IEnumerable SearchRegex(IEnumerable files, string query) + { + var regex = new Regex(query, RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)); + return files.Where(path => IsTextMatch(path, regex.IsMatch)); + } + private static IEnumerable ExistingAclTargets(string path) + { + if (File.Exists(path)) + { + yield return path; + yield break; + } + if (!Directory.Exists(path)) yield break; + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = false, + ReturnSpecialDirectories = false, + AttributesToSkip = FileAttributes.ReparsePoint + }; + foreach (string entry in Directory.EnumerateFileSystemEntries(path, "*", options) + .OrderByDescending(static entry => entry.Count(character => character == Path.DirectorySeparatorChar))) + yield return entry; + yield return path; + } + private static string ApplyPatch(string path, string patch) + { + string current = File.Exists(path) ? File.ReadAllText(path) : string.Empty; + using JsonDocument document = JsonDocument.Parse(patch); + string oldText = document.RootElement.GetProperty("old_text").GetString()!; + string newText = document.RootElement.GetProperty("new_text").GetString()!; + int first = current.IndexOf(oldText, StringComparison.Ordinal); + if (first < 0 || current.IndexOf(oldText, first + oldText.Length, StringComparison.Ordinal) >= 0) throw new InvalidOperationException("Patch old_text must match exactly once."); + return current[..first] + newText + current[(first + oldText.Length)..]; + } + + private sealed class TaskContext(TaskResourceJournal journal, TaskAclManager acl) + { + public TaskResourceJournal Journal { get; } = journal; + public TaskAclManager Acl { get; } = acl; + public List Acls { get; } = []; + public HashSet Roots { get; } = new(StringComparer.OrdinalIgnoreCase); + public TaskNetworkPolicy? Network { get; set; } + } +} -- cgit v1.2.3