summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Agent.App/Agent.App.csproj24
-rw-r--r--src/Agent.App/Cli/CommandLine.cs39
-rw-r--r--src/Agent.App/Configuration/AgentConfiguration.cs76
-rw-r--r--src/Agent.App/Configuration/ConfigurationException.cs4
-rw-r--r--src/Agent.App/Configuration/ConfigurationLoader.cs85
-rw-r--r--src/Agent.App/Configuration/OllamaSettingsFactory.cs39
-rw-r--r--src/Agent.App/Logging/JsonLinesRuntimeObserver.cs37
-rw-r--r--src/Agent.App/Logging/RollingFileLog.cs55
-rw-r--r--src/Agent.App/Program.cs130
-rw-r--r--src/Agent.Core/Agent.Core.csproj8
-rw-r--r--src/Agent.Core/AssemblyMarker.cs3
-rw-r--r--src/Agent.Core/Localization/LanguageContext.cs3
-rw-r--r--src/Agent.Core/Localization/LocaleCatalog.cs97
-rw-r--r--src/Agent.Core/Localization/LocaleException.cs4
-rw-r--r--src/Agent.Core/Ollama/IOllamaClient.cs11
-rw-r--r--src/Agent.Core/Ollama/OllamaClient.cs92
-rw-r--r--src/Agent.Core/Ollama/OllamaException.cs7
-rw-r--r--src/Agent.Core/Ollama/OllamaModels.cs46
-rw-r--r--src/Agent.Core/Ollama/OllamaSettings.cs10
-rw-r--r--src/Agent.Core/Prompts/FixedPrompt.cs16
-rw-r--r--src/Agent.Core/Prompts/FixedSystemPrompt.md4
-rw-r--r--src/Agent.Core/Prompts/PromptComposer.cs36
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs165
-rw-r--r--src/Agent.Core/Runtime/AgentRuntimeSettings.cs10
-rw-r--r--src/Agent.Core/Runtime/IAgentRuntimeObserver.cs19
-rw-r--r--src/Agent.Core/Sessions/AgentSession.cs26
-rw-r--r--src/Agent.Core/Sessions/ConversationMessage.cs3
-rw-r--r--src/Agent.Core/Sessions/SessionStore.cs19
-rw-r--r--src/Agent.Core/Tasks/AgentTaskState.cs11
-rw-r--r--src/Agent.Core/Tasks/TaskRegistry.cs115
-rw-r--r--src/Agent.Core/Tasks/TaskSnapshot.cs16
-rw-r--r--src/Agent.Core/Tools/InternalToolCatalog.cs32
-rw-r--r--src/Agent.Core/Tools/InternalToolDefinition.cs5
-rw-r--r--src/Agent.Core/Tools/InternalToolDispatcher.cs29
-rw-r--r--src/Agent.Core/Tools/InternalToolValidator.cs86
-rw-r--r--src/Agent.Core/Tools/TaskGraphToolHandler.cs123
-rw-r--r--src/Agent.Core/Tools/ToolPolicy.cs31
-rw-r--r--src/Agent.Core/Tools/ToolUseGuard.cs22
-rw-r--r--src/Agent.Core/Tools/ToolValidationException.cs3
-rw-r--r--src/Agent.Mcp/Agent.Mcp.csproj12
-rw-r--r--src/Agent.Mcp/AgentTools.cs90
-rw-r--r--src/Agent.Mcp/AssemblyMarker.cs3
-rw-r--r--src/Agent.Mcp/McpHost.cs136
-rw-r--r--src/Agent.Mcp/McpImageInput.cs12
-rw-r--r--src/Agent.Mcp/QueuedTaskWorker.cs18
-rw-r--r--src/Agent.Mcp/VisionLimits.cs3
-rw-r--r--src/Agent.Planner/Agent.Planner.csproj1
-rw-r--r--src/Agent.Planner/AssemblyMarker.cs3
-rw-r--r--src/Agent.Planner/PlannerTask.cs18
-rw-r--r--src/Agent.Planner/PlannerTaskState.cs11
-rw-r--r--src/Agent.Planner/PlannerValidationException.cs3
-rw-r--r--src/Agent.Planner/TaskGraph.cs179
-rw-r--r--src/Agent.Planner/TokenBudgetClass.cs31
-rw-r--r--src/Agent.Planner/TokenEstimate.cs15
-rw-r--r--src/Agent.Windows/Agent.Windows.csproj17
-rw-r--r--src/Agent.Windows/AssemblyMarker.cs3
-rw-r--r--src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs50
-rw-r--r--src/Agent.Windows/NativeMethods.txt46
-rw-r--r--src/Agent.Windows/Network/LoopbackExemption.cs123
-rw-r--r--src/Agent.Windows/Network/TaskNetworkPolicy.cs73
-rw-r--r--src/Agent.Windows/Network/WfpDynamicSession.cs208
-rw-r--r--src/Agent.Windows/Recovery/RecoveryManager.cs114
-rw-r--r--src/Agent.Windows/Recovery/TaskResourceJournal.cs108
-rw-r--r--src/Agent.Windows/Recovery/TransactionalFileEditor.cs141
-rw-r--r--src/Agent.Windows/Security/AppContainerProfile.cs41
-rw-r--r--src/Agent.Windows/Security/ExecutablePolicy.cs51
-rw-r--r--src/Agent.Windows/Security/PathPolicy.cs52
-rw-r--r--src/Agent.Windows/Security/RestrictedProcessLauncher.cs255
-rw-r--r--src/Agent.Windows/Security/SecurityAudit.cs29
-rw-r--r--src/Agent.Windows/Security/TaskAclManager.cs76
-rw-r--r--src/Agent.Windows/Security/UserOnlyDirectory.cs20
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs257
72 files changed, 3740 insertions, 0 deletions
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 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <OutputType>Exe</OutputType>
+ <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
+ <AssemblyName>agent-mcp</AssemblyName>
+ <RootNamespace>Agent.App</RootNamespace>
+ <Version>1.0.0</Version>
+ </PropertyGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\Agent.Mcp\Agent.Mcp.csproj" />
+ <ProjectReference Include="..\Agent.Windows\Agent.Windows.csproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.Extensions.Hosting" />
+ <PackageReference Include="YamlDotNet" />
+ </ItemGroup>
+ <ItemGroup>
+ <Content Include="..\..\config\agent.yaml" Link="agent.yaml" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
+ <Content Include="..\..\config\locales\**\*.json" Link="config\locales\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
+ <Content Include="..\..\prompts\user-system.md" Link="prompts\user-system.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
+ <Content Include="..\..\skills\.keep" Link="skills\.keep" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
+ <Content Include="..\..\README.md" Link="README.md" CopyToPublishDirectory="PreserveNewest" />
+ </ItemGroup>
+</Project>
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<string, object?> 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<string> 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<string> Read { get; set; } = []; public List<string> Write { get; set; } = []; public List<string> Execute { get; set; } = []; public List<string> Delete { get; set; } = []; }
+public sealed class ProcessOptions { public bool AllowShell { get; set; } = true; public List<ExecutableApproval> Approvals { get; set; } = []; public int MaxProcesses { get; set; } = 16; }
+public sealed class ExecutableApproval { public string Path { get; set; } = string.Empty; public string Sha256 { get; set; } = string.Empty; }
+public sealed class NetworkOptions { public string Mode { get; set; } = "deny"; public List<NetworkAllowance> Allow { get; set; } = []; }
+public sealed class NetworkAllowance { public string Host { get; set; } = string.Empty; public List<int> Ports { get; set; } = []; }
+public sealed class ResourceOptions { public int MemoryMb { get; set; } = 4096; public int ProcessTimeoutSeconds { get; set; } = 1800; }
+public sealed class 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<AgentConfiguration>(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<T>(Dictionary<string, JsonElement> 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<OllamaMessage> 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<int> 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<string, object?> { ["message"] = detail });
+ log.Error(failure);
+ Console.Error.WriteLine(failure);
+ return 2;
+ }
+ log.Information(locale.Get("check.success"));
+ Console.Out.WriteLine(locale.Get("check.success"));
+ return 0;
+ }
+
+ 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<FilePolicyEntry>();
+ Add(configuration.Sandbox.Filesystem.Read, FileCapability.Read);
+ Add(configuration.Sandbox.Filesystem.Write, FileCapability.Write);
+ Add(configuration.Sandbox.Filesystem.Execute, FileCapability.Execute);
+ Add(configuration.Sandbox.Filesystem.Delete, FileCapability.Delete);
+ var settings = new WindowsSandboxSettings(
+ fileEntries,
+ configuration.Sandbox.Process.Approvals.Select(approval => new Agent.Windows.Security.ExecutableApproval(approval.Path, approval.Sha256)).ToArray(),
+ configuration.Sandbox.Process.AllowShell,
+ new SandboxJobLimits((uint)configuration.Sandbox.Process.MaxProcesses, checked((ulong)configuration.Sandbox.Resources.MemoryMb * 1024 * 1024)),
+ configuration.Sandbox.Network.Mode,
+ configuration.Sandbox.Network.Allow.Select(item => (item.Host, (IReadOnlyList<int>)item.Ports)).ToArray(),
+ journalDirectory, backupDirectory, checked((long)configuration.FileEdit.MemoryBackupLimitMb * 1024 * 1024),
+ configuration.Limits.WholeReadBytes, configuration.Limits.SearchMaxResults, configuration.Limits.TailMaxLines);
+ 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<string> 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 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <ItemGroup>
+ <ProjectReference Include="..\Agent.Planner\Agent.Planner.csproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Prompts\FixedSystemPrompt.md" />
+ </ItemGroup>
+</Project>
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<string, IReadOnlyDictionary<string, string>> _locales;
+ private readonly IReadOnlyDictionary<string, string> _fallback;
+
+ private LocaleCatalog(string selectedLocale, IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> locales, IReadOnlyDictionary<string, string> fallback)
+ {
+ SelectedLocale = selectedLocale;
+ _locales = locales;
+ _fallback = fallback;
+ }
+
+ public string SelectedLocale { get; }
+ public IReadOnlyCollection<string> 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<string, IReadOnlyDictionary<string, string>>(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<string, string>? fallback))
+ throw new LocaleException($"Fallback locale '{fallbackLocale}' is missing.");
+
+ HashSet<string> baselineKeys = fallback.Keys.ToHashSet(StringComparer.Ordinal);
+ Dictionary<string, HashSet<string>> baselinePlaceholders = fallback.ToDictionary(static p => p.Key, static p => Placeholders(p.Value), StringComparer.Ordinal);
+ foreach ((string locale, IReadOnlyDictionary<string, string> 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<string, object?> 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<string, string> 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<string, string>(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<string> 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<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken);
+ Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) =>
+ ChatAsync(messages, temperatureOverride, cancellationToken);
+ Task<bool?> SupportsVisionAsync(CancellationToken cancellationToken) => Task.FromResult<bool?>(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<TimeSpan, CancellationToken, Task> _delay;
+ private bool? _visionCapability;
+
+ public OllamaClient(HttpClient httpClient, OllamaSettings settings, Func<TimeSpan, CancellationToken, Task>? delay = null)
+ {
+ _httpClient = httpClient;
+ _httpClient.BaseAddress = settings.Endpoint;
+ _settings = settings;
+ _delay = delay ?? Task.Delay;
+ }
+
+ public async Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken)
+ => await ChatCoreAsync(messages, temperatureOverride, null, cancellationToken).ConfigureAwait(false);
+
+ public async Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken)
+ => await ChatCoreAsync(messages, temperatureOverride, tools, cancellationToken).ConfigureAwait(false);
+
+ public async Task<bool?> 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<OllamaResult> ChatCoreAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition>? tools, CancellationToken cancellationToken)
+ {
+ Dictionary<string, JsonElement> 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<OllamaChatResponse>(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<string>? Images = null,
+ [property: JsonPropertyName("tool_name"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ToolName = null,
+ [property: JsonPropertyName("tool_calls"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<OllamaToolCall>? 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<OllamaMessage> 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<string, System.Text.Json.JsonElement> Options { get; init; }
+ [JsonPropertyName("tools"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IReadOnlyList<OllamaToolDefinition>? 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<OllamaToolCall>? 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<string, JsonElement> 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<string> Skills,
+ string SandboxCapabilitySummary,
+ string TaskGraphSummary,
+ string PreviousSummary,
+ IReadOnlyList<ConversationMessage> Conversation,
+ string Request,
+ IReadOnlyList<string>? Images = null);
+
+public static class PromptComposer
+{
+ public static IReadOnlyList<OllamaMessage> Compose(PromptContext context)
+ {
+ var messages = new List<OllamaMessage>
+ {
+ 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<string, string>(StringComparer.Ordinal);
+ IReadOnlyDictionary<string, int> requiredToolCalls = RequiredToolCalls(task.Request);
+ var successfulToolCalls = new Dictionary<string, int>(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<OllamaToolCall> 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<string?> 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<string, int> RequiredToolCalls(string request)
+ {
+ var result = new Dictionary<string, int>(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<string> 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<OllamaMessage> 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<OllamaMessage> 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<ConversationMessage> _conversation = [];
+
+ public Guid SessionId { get; } = sessionId;
+ public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset LastAccessAt { get; private set; } = DateTimeOffset.UtcNow;
+
+ public IReadOnlyList<ConversationMessage> 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<Guid, AgentSession> _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<Guid, MutableTask> _tasks = new();
+ private readonly SessionStore _sessions;
+ private readonly Channel<Guid> _queue = Channel.CreateUnbounded<Guid>(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<string>? 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<Guid> 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<string> 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<string> 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<string, InternalToolDefinition> Definitions = CreateDefinitions();
+
+ public static IReadOnlyList<InternalToolDefinition> 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<string, InternalToolDefinition> 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<ToolExecutionResult> 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<ToolExecutionResult> 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<string> 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<Guid, TaskGraph> _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<Guid>();
+ 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<ToolExecutionResult> 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<string>? allowedTools = null) : IToolPolicyEngine
+{
+ private readonly IReadOnlySet<string> _allowedTools = allowedTools ?? new HashSet<string>(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<string, int> _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 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <ItemGroup>
+ <ProjectReference Include="..\Agent.Core\Agent.Core.csproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <PackageReference Include="ModelContextProtocol" />
+ <PackageReference Include="ModelContextProtocol.AspNetCore" />
+ </ItemGroup>
+ <ItemGroup>
+ <FrameworkReference Include="Microsoft.AspNetCore.App" />
+ </ItemGroup>
+</Project>
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<CallToolRequestParams> 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<McpImageInput>? 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<string, object?>
+ {
+ ["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<McpImageInput>? 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<CallToolRequestParams> 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<string, object?> { ["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<CallToolRequestParams> context, params string[] allowed)
+ {
+ if (context.Params?.Arguments is null) return;
+ HashSet<string> 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<QueuedTaskWorker>();
+
+ 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<QueuedTaskWorker>();
+ 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<McpServerToolAttribute>() 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<string, JsonNode?> 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 @@
+<Project Sdk="Microsoft.NET.Sdk" />
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<Guid> 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<Guid> 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<Guid, MutableTask> _tasks = [];
+ private readonly List<Guid> _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<PlannerTask> Tasks => _insertionOrder.Select(id => _tasks[id].Snapshot()).ToArray();
+
+ public PlannerTask AddTask(
+ string description,
+ TokenBudgetClass budgetClass,
+ TokenEstimate estimate,
+ IEnumerable<Guid>? 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<Guid> 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<Guid> order = TopologicalOrder();
+ var costs = new Dictionary<Guid, double>();
+ var predecessors = new Dictionary<Guid, Guid?>();
+ 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<Guid>();
+ 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<Guid> TopologicalOrder()
+ {
+ var indegree = _tasks.ToDictionary(static pair => pair.Key, static pair => pair.Value.Dependencies.Count);
+ var ready = new SortedSet<int>(_insertionOrder.Select((id, index) => (id, index)).Where(item => indegree[item.id] == 0).Select(static item => item.index));
+ var result = new List<Guid>(_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<Guid> 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<Guid> dependencies, TokenBudgetClass budgetClass, TokenEstimate estimate, bool requiresExternalEffect)
+ {
+ public Guid Id { get; } = id;
+ public string Description { get; } = description;
+ public HashSet<Guid> 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 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+ <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
+ <CompilerGeneratedFilesOutputPath>$(IntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
+ </PropertyGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\Agent.Core\Agent.Core.csproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.Windows.CsWin32" PrivateAssets="all" />
+ </ItemGroup>
+ <ItemGroup>
+ <InternalsVisibleTo Include="Agent.Windows.Tests" />
+ </ItemGroup>
+</Project>
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<ulong> 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<Entry> 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<Entry> 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<Entry> Read()
+ {
+ uint result = PInvoke.NetworkIsolationGetAppContainerConfig(out uint count, out SID_AND_ATTRIBUTES* native);
+ ThrowIfError(result, "NetworkIsolationGetAppContainerConfig");
+ try
+ {
+ var entries = new List<Entry>(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<Entry> entries)
+ {
+ var native = new SID_AND_ATTRIBUTES[entries.Count];
+ var allocations = new List<nint>(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<NetworkEndpoint> allowances, ISecurityAuditSink audit)
+ {
+ bool originallyExempt = LoopbackExemption.Contains(appContainerSid);
+ Guid loopbackEntryId = journal.Prepare("network.loopback_exemption", appContainerSid.Value,
+ new Dictionary<string, string> { ["originally_present"] = originallyExempt.ToString() });
+ bool loopbackAdded = false;
+ Guid entryId = journal.Prepare("wfp.dynamic", appContainerSid.Value, new Dictionary<string, string>
+ {
+ ["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<string, string> { ["added"] = loopbackAdded.ToString() });
+ IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(appContainerSid, allowances);
+ journal.MarkApplied(entryId, new Dictionary<string, string> { ["filter_ids"] = string.Join(',', ids) });
+ audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "wfp.apply", "applied", new Dictionary<string, string>
+ {
+ ["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<string, string> { ["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<string, string> { ["residual_filters"] = "0" }));
+ _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed",
+ new Dictionary<string, string> { ["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<IReadOnlyList<NetworkEndpoint>> ResolveAsync(IEnumerable<(string Host, IReadOnlyList<int> Ports)> allowances, CancellationToken cancellationToken)
+ {
+ var endpoints = new HashSet<NetworkEndpoint>();
+ foreach ((string host, IReadOnlyList<int> 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<ulong> _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<ulong> ApplyDefaultBlock(SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint>? 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<string> 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<string>();
+ 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<string, string>()));
+ }
+ 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<string, string> 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<JournalEntry> 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<JournalEntry> 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<JournalDocument>(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<string, string> 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<string, string>? 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<byte> 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<string, string>
+ {
+ ["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<string, string> { ["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<string, string>
+ {
+ ["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<ExecutableApproval> approvals, bool allowShell)
+{
+ private readonly IReadOnlyDictionary<string, string> _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<string> 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<FilePolicyEntry> entries)
+{
+ private readonly IReadOnlyList<FilePolicyEntry> _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<string> 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<string, string> { ["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<char> commandBuffer = new char[commandLine.Length + 1];
+ commandLine.AsSpan().CopyTo(commandBuffer);
+ if (!PInvoke.CreateProcess(null, ref commandBuffer, null, null, true,
+ PROCESS_CREATION_FLAGS.EXTENDED_STARTUPINFO_PRESENT | PROCESS_CREATION_FLAGS.CREATE_SUSPENDED,
+ null, cwd, in startup.StartupInfo, out process)) throw LastError("CreateProcess");
+ 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<string, string>
+ {
+ ["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<string, string>
+ {
+ ["path"] = verified.CanonicalPath, ["sha256"] = verified.Sha256, ["child_process_policy"] = "restricted",
+ ["app_container_sid"] = appContainerSid?.Value ?? "none", ["network_capabilities"] = appContainerSid is null ? "none" : "internetClient,privateNetworkClientServer",
+ ["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<string, string> { ["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<string, string> { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() }));
+ if (exitCode != 0)
+ {
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "observed", new Dictionary<string, string>
+ {
+ ["code"] = "restricted_process_failure", ["pid"] = process.dwProcessId.ToString(),
+ ["child_process_policy"] = "restricted", ["exit_code"] = exitCode.ToString()
+ }));
+ }
+ 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<string> 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<string, string> 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<SecurityAuditEvent> _events = [];
+ public IReadOnlyList<SecurityAuditEvent> 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<string, string>
+ {
+ ["security_descriptor"] = Convert.ToBase64String(original)
+ });
+ FileSystemRights rights = ToRights(capabilities);
+ if (rights == 0) throw new ArgumentOutOfRangeException(nameof(capabilities));
+ bool directory = Directory.Exists(canonical);
+ var rule = new FileSystemAccessRule(appContainerSid, rights,
+ directory ? InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit : InheritanceFlags.None,
+ PropagationFlags.None, AccessControlType.Allow);
+ security.AddAccessRule(rule);
+ WriteSecurity(canonical, security);
+ if (journalEntry is not null) journal!.MarkApplied(journalEntry.Value);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.apply", "applied", new Dictionary<string, string> { ["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<string, string> { ["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<FileSystemAccessRule>()
+ .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<string, string> { ["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<FilePolicyEntry> Files,
+ IReadOnlyList<ExecutableApproval> Executables,
+ bool AllowShell,
+ SandboxJobLimits JobLimits,
+ string NetworkMode,
+ IReadOnlyList<(string Host, IReadOnlyList<int> 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<Guid, TaskContext> _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<NetworkEndpoint> 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<ToolExecutionResult> 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<string> files = File.Exists(canonical) ? [canonical] : Directory.EnumerateFiles(canonical, "*", new EnumerationOptions
+ {
+ RecurseSubdirectories = true,
+ IgnoreInaccessible = true,
+ ReturnSpecialDirectories = false,
+ AttributesToSkip = FileAttributes.ReparsePoint
+ });
+ IEnumerable<string> 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<string, string> { ["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<Exception> 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<string, bool> 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<string> SearchRegex(IEnumerable<string> files, string query)
+ {
+ var regex = new Regex(query, RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
+ return files.Where(path => IsTextMatch(path, regex.IsMatch));
+ }
+ private static IEnumerable<string> 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<AclSnapshot> Acls { get; } = [];
+ public HashSet<string> Roots { get; } = new(StringComparer.OrdinalIgnoreCase);
+ public TaskNetworkPolicy? Network { get; set; }
+ }
+}