diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-09 21:08:20 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-09 21:08:20 +0900 |
| commit | b07b41cd435991cd254957af79055080c85b346e (patch) | |
| tree | 27720d3ce14e4d0b84670a55ac26a2f877ce3a20 /src/Agent.App/Configuration | |
| parent | 2fefd29e86c3b926d0cf4a362251d3f119688612 (diff) | |
feat: AI Agent Sandbox MCP Server v1を実装
Specifications、Design、ImplementationPlanの優先順位を整理し、単一Task実行モデル、typed option優先、設定可能なresource上限、local Ollamaを必須とするRelease Gateを文書化。
.NET 10/C# 14の5 production project、3 test project、E2E Clientを構築。typed YAML、en/ja locale、fixed/user prompt、stdioおよびloopback限定Streamable HTTP、agent_run/agent_status、Session、Ollama text/tool/Vision、TaskGraphとtoken accountingを実装。
Windows sandboxとしてfixed AppContainer、canonical/reparse検査、能力別ACL、path+SHA-256 executable approval、child-process restriction、Job Object、task journal、atomic file transaction、crash recoveryを実装。WFPのIPv4/IPv6 default denyとexact allow、dynamic session、temporary loopback exemption、管理者/BFE診断を追加。
Security/Recovery test、MCP/Ollama integration test、stdio/HTTP Operational E2E、Release Bundle生成・checksum・manifest・fresh extraction検査を追加。Requirement TraceabilityとPhase 0〜11の進捗、初回セットアップ可能なREADMEを整備。
Debug/Release/publish/objをrepository rootのbuild/へ集約し、旧project-local bin/objへの依存を除去。artifacts/とbuild/をGit管理外に設定し、clean checkoutでもsolution buildからE2E Clientまで生成可能にした。
Diffstat (limited to 'src/Agent.App/Configuration')
4 files changed, 204 insertions, 0 deletions
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); + } +} |
