From b07b41cd435991cd254957af79055080c85b346e Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 9 Aug 2026 21:08:20 +0900 Subject: feat: AI Agent Sandbox MCP Server v1を実装 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specifications、Design、ImplementationPlanの優先順位を整理し、単一Task実行モデル、typed option優先、設定可能なresource上限、local Ollamaを必須とするRelease Gateを文書化。 .NET 10/C# 14の5 production project、3 test project、E2E Clientを構築。typed YAML、en/ja locale、fixed/user prompt、stdioおよびloopback限定Streamable HTTP、agent_run/agent_status、Session、Ollama text/tool/Vision、TaskGraphとtoken accountingを実装。 Windows sandboxとしてfixed AppContainer、canonical/reparse検査、能力別ACL、path+SHA-256 executable approval、child-process restriction、Job Object、task journal、atomic file transaction、crash recoveryを実装。WFPのIPv4/IPv6 default denyとexact allow、dynamic session、temporary loopback exemption、管理者/BFE診断を追加。 Security/Recovery test、MCP/Ollama integration test、stdio/HTTP Operational E2E、Release Bundle生成・checksum・manifest・fresh extraction検査を追加。Requirement TraceabilityとPhase 0〜11の進捗、初回セットアップ可能なREADMEを整備。 Debug/Release/publish/objをrepository rootのbuild/へ集約し、旧project-local bin/objへの依存を除去。artifacts/とbuild/をGit管理外に設定し、clean checkoutでもsolution buildからE2E Clientまで生成可能にした。 --- src/Agent.Core/Tools/InternalToolCatalog.cs | 32 ++++++ src/Agent.Core/Tools/InternalToolDefinition.cs | 5 + src/Agent.Core/Tools/InternalToolDispatcher.cs | 29 ++++++ src/Agent.Core/Tools/InternalToolValidator.cs | 86 +++++++++++++++++ src/Agent.Core/Tools/TaskGraphToolHandler.cs | 123 ++++++++++++++++++++++++ src/Agent.Core/Tools/ToolPolicy.cs | 31 ++++++ src/Agent.Core/Tools/ToolUseGuard.cs | 22 +++++ src/Agent.Core/Tools/ToolValidationException.cs | 3 + 8 files changed, 331 insertions(+) create mode 100644 src/Agent.Core/Tools/InternalToolCatalog.cs create mode 100644 src/Agent.Core/Tools/InternalToolDefinition.cs create mode 100644 src/Agent.Core/Tools/InternalToolDispatcher.cs create mode 100644 src/Agent.Core/Tools/InternalToolValidator.cs create mode 100644 src/Agent.Core/Tools/TaskGraphToolHandler.cs create mode 100644 src/Agent.Core/Tools/ToolPolicy.cs create mode 100644 src/Agent.Core/Tools/ToolUseGuard.cs create mode 100644 src/Agent.Core/Tools/ToolValidationException.cs (limited to 'src/Agent.Core/Tools') diff --git a/src/Agent.Core/Tools/InternalToolCatalog.cs b/src/Agent.Core/Tools/InternalToolCatalog.cs new file mode 100644 index 0000000..1563c16 --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolCatalog.cs @@ -0,0 +1,32 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public static class InternalToolCatalog +{ + private static readonly IReadOnlyDictionary Definitions = CreateDefinitions(); + + public static IReadOnlyList All { get; } = Definitions.Values.ToArray(); + public static InternalToolDefinition Get(string name) => Definitions.TryGetValue(name, out InternalToolDefinition? definition) + ? definition + : throw new ToolValidationException($"Unknown internal tool '{name}'."); + + private static IReadOnlyDictionary CreateDefinitions() + { + InternalToolDefinition[] definitions = + [ + Define("file_search", "Search files under an approved root.", """{"type":"object","additionalProperties":false,"required":["root","mode","query"],"properties":{"root":{"type":"string"},"mode":{"type":"string","enum":["glob","name","text","regex","tail"]},"query":{"type":"string"},"max_results":{"type":"integer","minimum":1,"maximum":1000},"tail_lines":{"type":"integer","minimum":1,"maximum":10000}}}"""), + Define("file_read", "Read an approved file.", """{"type":"object","additionalProperties":false,"required":["path","mode"],"properties":{"path":{"type":"string"},"mode":{"type":"string","enum":["whole","range","metadata"]},"start":{"type":"integer","minimum":0},"length":{"type":"integer","minimum":0}}}"""), + Define("file_edit", "Edit an approved file transactionally.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["overwrite","patch","atomic_replace","delete"]},"content":{"type":["string","null"]},"patch":{"type":["string","null"]},"expected_sha256":{"type":["string","null"]}}}"""), + Define("process_run", "Run one approved executable with separated arguments.", """{"type":"object","additionalProperties":false,"required":["executable","args","cwd"],"properties":{"executable":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":"string"},"timeout_seconds":{"type":"integer","minimum":1},"stdin":{"type":["string","null"]}}}"""), + Define("task_update", "Update the deterministic task graph.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","set_state","set_estimate"]},"task_id":{"type":["string","null"]},"description":{"type":["string","null"]},"depends_on":{"type":"array","items":{"type":"string"}},"budget_class":{"type":["string","null"],"enum":["1K","2K","4K","8K","16K","32K","64K","128K",null]},"optimistic_tokens":{"type":"integer","minimum":0},"most_likely_tokens":{"type":"integer","minimum":0},"pessimistic_tokens":{"type":"integer","minimum":0},"state":{"type":["string","null"],"enum":["queued","planning","running","completed","failed","cancelled",null]}}}""") + ]; + return definitions.ToDictionary(static definition => definition.Name, StringComparer.Ordinal); + } + + private static InternalToolDefinition Define(string name, string description, string schema) + { + using JsonDocument document = JsonDocument.Parse(schema); + return new(name, description, document.RootElement.Clone()); + } +} diff --git a/src/Agent.Core/Tools/InternalToolDefinition.cs b/src/Agent.Core/Tools/InternalToolDefinition.cs new file mode 100644 index 0000000..8f06764 --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolDefinition.cs @@ -0,0 +1,5 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public sealed record InternalToolDefinition(string Name, string Description, JsonElement Parameters); diff --git a/src/Agent.Core/Tools/InternalToolDispatcher.cs b/src/Agent.Core/Tools/InternalToolDispatcher.cs new file mode 100644 index 0000000..7302c1f --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolDispatcher.cs @@ -0,0 +1,29 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public sealed class InternalToolDispatcher(IToolPolicyEngine policy, IInternalToolBackend backend) +{ + public Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) => backend.BeginTaskAsync(taskId, cancellationToken); + public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => backend.EndTaskAsync(taskId, success, cancellationToken); + + public async Task DispatchAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) + { + InternalToolValidator.Validate(toolName, arguments); + ToolPolicyDecision decision = policy.Evaluate(toolName, arguments); + if (!decision.Allowed) return new(false, decision.Reason, false, $"policy:{toolName}:{decision.Reason}"); + return await backend.ExecuteAsync(taskId, toolName, arguments, cancellationToken).ConfigureAwait(false); + } +} + +public sealed class FakeInternalToolBackend : IInternalToolBackend +{ + private readonly List<(string Name, JsonElement Arguments)> _calls = []; + public IReadOnlyList<(string Name, JsonElement Arguments)> Calls => _calls; + public Task ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) + { + _calls.Add((toolName, arguments.Clone())); + bool effect = toolName is "file_edit" or "process_run"; + return Task.FromResult(new ToolExecutionResult(true, "fake-ok", effect)); + } +} diff --git a/src/Agent.Core/Tools/InternalToolValidator.cs b/src/Agent.Core/Tools/InternalToolValidator.cs new file mode 100644 index 0000000..c48719e --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolValidator.cs @@ -0,0 +1,86 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public static class InternalToolValidator +{ + public static void Validate(string name, JsonElement arguments) + { + InternalToolDefinition definition = InternalToolCatalog.Get(name); + if (arguments.ValueKind != JsonValueKind.Object) throw new ToolValidationException("Tool arguments must be an object."); + JsonElement properties = definition.Parameters.GetProperty("properties"); + var known = properties.EnumerateObject().Select(static item => item.Name).ToHashSet(StringComparer.Ordinal); + foreach (JsonProperty argument in arguments.EnumerateObject()) + { + if (!known.Contains(argument.Name)) throw new ToolValidationException($"Unknown argument '{argument.Name}'."); + ValidateValue(argument.Name, argument.Value, properties.GetProperty(argument.Name)); + } + foreach (JsonElement required in definition.Parameters.GetProperty("required").EnumerateArray()) + { + string requiredName = required.GetString()!; + if (!arguments.TryGetProperty(requiredName, out _)) throw new ToolValidationException($"Missing required argument '{requiredName}'."); + } + ValidateOperationSpecific(name, arguments); + } + + private static void ValidateValue(string name, JsonElement value, JsonElement schema) + { + if (value.ValueKind == JsonValueKind.Null && AllowsNull(schema)) return; + bool validType = GetTypes(schema).Any(type => type switch + { + "string" => value.ValueKind == JsonValueKind.String, + "integer" => value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out _), + "array" => value.ValueKind == JsonValueKind.Array, + _ => false + }); + if (!validType) throw new ToolValidationException($"Argument '{name}' has an invalid type."); + if (schema.TryGetProperty("enum", out JsonElement enumValues) && !enumValues.EnumerateArray().Any(item => JsonElement.DeepEquals(item, value))) + throw new ToolValidationException($"Argument '{name}' has an unsupported value."); + if (value.ValueKind == JsonValueKind.Number) + { + long number = value.GetInt64(); + if (schema.TryGetProperty("minimum", out JsonElement minimum) && number < minimum.GetInt64()) throw new ToolValidationException($"Argument '{name}' is below its minimum."); + if (schema.TryGetProperty("maximum", out JsonElement maximum) && number > maximum.GetInt64()) throw new ToolValidationException($"Argument '{name}' exceeds its maximum."); + } + if (value.ValueKind == JsonValueKind.Array && schema.TryGetProperty("items", out JsonElement items)) + foreach (JsonElement item in value.EnumerateArray()) ValidateValue(name, item, items); + } + + private static IEnumerable GetTypes(JsonElement schema) + { + JsonElement type = schema.GetProperty("type"); + return type.ValueKind == JsonValueKind.Array + ? type.EnumerateArray().Select(static item => item.GetString()!).ToArray() + : [type.GetString()!]; + } + private static bool AllowsNull(JsonElement schema) => GetTypes(schema).Contains("null", StringComparer.Ordinal); + + private static void ValidateOperationSpecific(string name, JsonElement arguments) + { + if (name == "file_read" && arguments.GetProperty("mode").GetString() == "range" && !arguments.TryGetProperty("length", out _)) + throw new ToolValidationException("Range reads require length."); + if (name == "file_edit") + { + string operation = arguments.GetProperty("operation").GetString()!; + if (operation is "overwrite" or "atomic_replace" && (!arguments.TryGetProperty("content", out JsonElement content) || content.ValueKind != JsonValueKind.String)) + throw new ToolValidationException($"{operation} requires content."); + if (operation == "patch" && (!arguments.TryGetProperty("patch", out JsonElement patch) || patch.ValueKind != JsonValueKind.String)) + throw new ToolValidationException("patch requires patch text."); + } + if (name == "task_update") + { + string operation = arguments.GetProperty("operation").GetString()!; + if (operation == "add") Require(arguments, "description", "budget_class", "optimistic_tokens", "most_likely_tokens", "pessimistic_tokens"); + if (operation == "add_dependency") Require(arguments, "task_id", "depends_on"); + if (operation == "set_state") Require(arguments, "task_id", "state"); + if (operation == "set_estimate") Require(arguments, "task_id", "budget_class", "optimistic_tokens", "most_likely_tokens", "pessimistic_tokens"); + } + + static void Require(JsonElement value, params string[] properties) + { + foreach (string property in properties) + if (!value.TryGetProperty(property, out JsonElement item) || item.ValueKind == JsonValueKind.Null) + throw new ToolValidationException($"{property} is required for this operation."); + } + } +} diff --git a/src/Agent.Core/Tools/TaskGraphToolHandler.cs b/src/Agent.Core/Tools/TaskGraphToolHandler.cs new file mode 100644 index 0000000..03daa56 --- /dev/null +++ b/src/Agent.Core/Tools/TaskGraphToolHandler.cs @@ -0,0 +1,123 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Agent.Planner; + +namespace Agent.Core.Tools; + +public interface ITaskUpdateHandler +{ + void BeginTask(Guid taskId); + ToolExecutionResult Execute(Guid taskId, JsonElement arguments); + void EndTask(Guid taskId); +} + +public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler +{ + private readonly ConcurrentDictionary _graphs = new(); + + public void BeginTask(Guid taskId) + { + if (!_graphs.TryAdd(taskId, new TaskGraph(maxTasks))) + throw new InvalidOperationException($"TaskGraph for '{taskId}' already exists."); + } + + public ToolExecutionResult Execute(Guid taskId, JsonElement arguments) + { + if (!_graphs.TryGetValue(taskId, out TaskGraph? graph)) + throw new InvalidOperationException("TaskGraph scope is not active."); + + string operation = arguments.GetProperty("operation").GetString()!; + switch (operation) + { + case "add": + graph.AddTask( + RequiredString(arguments, "description"), + Budget(arguments), + Estimate(arguments), + Ids(arguments, "depends_on"), + id: OptionalId(arguments, "task_id")); + break; + case "add_dependency": + Guid taskIdValue = RequiredId(arguments, "task_id"); + Guid[] dependencies = Ids(arguments, "depends_on"); + if (dependencies.Length == 0) throw new ToolValidationException("depends_on must contain at least one task id."); + foreach (Guid dependency in dependencies) graph.AddDependency(taskIdValue, dependency); + break; + case "set_state": + graph.SetState(RequiredId(arguments, "task_id"), ParseState(RequiredString(arguments, "state"))); + break; + case "set_estimate": + graph.SetEstimate(RequiredId(arguments, "task_id"), Budget(arguments), Estimate(arguments)); + break; + default: + throw new ToolValidationException($"Unsupported task_update operation '{operation}'."); + } + + string output = JsonSerializer.Serialize(new + { + tasks = graph.Tasks.Select(static task => new + { + task_id = task.Id, + description = task.Description, + depends_on = task.Dependencies, + state = task.State.ToString().ToLowerInvariant(), + expected_tokens = task.ExpectedTokens + }), + critical_path = graph.CriticalPath.TaskIds, + critical_path_tokens = graph.CriticalPath.CumulativeTokenCost, + total_token_budget = graph.TotalTokenBudget + }); + return new ToolExecutionResult(true, output, false); + } + + public void EndTask(Guid taskId) => _graphs.TryRemove(taskId, out _); + + private static TokenBudgetClass Budget(JsonElement arguments) => + TokenBudgetClassExtensions.Parse(RequiredString(arguments, "budget_class")); + + private static TokenEstimate Estimate(JsonElement arguments) => new( + RequiredInt64(arguments, "optimistic_tokens"), + RequiredInt64(arguments, "most_likely_tokens"), + RequiredInt64(arguments, "pessimistic_tokens")); + + private static string RequiredString(JsonElement arguments, string property) => + arguments.TryGetProperty(property, out JsonElement value) && value.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(value.GetString()) + ? value.GetString()! + : throw new ToolValidationException($"{property} is required for this operation."); + + private static long RequiredInt64(JsonElement arguments, string property) => + arguments.TryGetProperty(property, out JsonElement value) && value.ValueKind == JsonValueKind.Number + ? value.GetInt64() + : throw new ToolValidationException($"{property} is required for this operation."); + + private static Guid RequiredId(JsonElement arguments, string property) => + OptionalId(arguments, property) ?? throw new ToolValidationException($"{property} must be a UUID for this operation."); + + private static Guid? OptionalId(JsonElement arguments, string property) + { + if (!arguments.TryGetProperty(property, out JsonElement value) || value.ValueKind == JsonValueKind.Null) return null; + return value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out Guid id) + ? id + : throw new ToolValidationException($"{property} must be a UUID."); + } + + private static Guid[] Ids(JsonElement arguments, string property) + { + if (!arguments.TryGetProperty(property, out JsonElement value)) return []; + var ids = new List(); + foreach (JsonElement item in value.EnumerateArray()) + ids.Add(Guid.TryParse(item.GetString(), out Guid id) ? id : throw new ToolValidationException($"{property} must contain only UUIDs.")); + return [.. ids]; + } + + private static PlannerTaskState ParseState(string state) => state switch + { + "queued" => PlannerTaskState.Queued, + "planning" => PlannerTaskState.Planning, + "running" => PlannerTaskState.Running, + "completed" => PlannerTaskState.Completed, + "failed" => PlannerTaskState.Failed, + "cancelled" => PlannerTaskState.Cancelled, + _ => throw new ToolValidationException($"Unknown task state '{state}'.") + }; +} diff --git a/src/Agent.Core/Tools/ToolPolicy.cs b/src/Agent.Core/Tools/ToolPolicy.cs new file mode 100644 index 0000000..c9f216a --- /dev/null +++ b/src/Agent.Core/Tools/ToolPolicy.cs @@ -0,0 +1,31 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public sealed record ToolPolicyDecision(bool Allowed, string Reason) +{ + public static ToolPolicyDecision Allow() => new(true, "allowed"); + public static ToolPolicyDecision Deny(string reason) => new(false, reason); +} + +public interface IToolPolicyEngine +{ + ToolPolicyDecision Evaluate(string toolName, JsonElement arguments); +} + +public interface IInternalToolBackend +{ + Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) => Task.CompletedTask; + Task ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken); + Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => Task.CompletedTask; +} + +public sealed record ToolExecutionResult(bool Success, string Output, bool ExternalEffect, string? FailureFingerprint = null); + +public sealed class DenyByDefaultPolicy(IReadOnlySet? allowedTools = null) : IToolPolicyEngine +{ + private readonly IReadOnlySet _allowedTools = allowedTools ?? new HashSet(StringComparer.Ordinal); + public ToolPolicyDecision Evaluate(string toolName, JsonElement arguments) => _allowedTools.Contains(toolName) + ? ToolPolicyDecision.Allow() + : ToolPolicyDecision.Deny("Capability is not allowed by policy."); +} diff --git a/src/Agent.Core/Tools/ToolUseGuard.cs b/src/Agent.Core/Tools/ToolUseGuard.cs new file mode 100644 index 0000000..633d34c --- /dev/null +++ b/src/Agent.Core/Tools/ToolUseGuard.cs @@ -0,0 +1,22 @@ +namespace Agent.Core.Tools; + +public enum ToolUseGuardDecision { Complete, Retry, Fail } + +public sealed record ToolUseGuardResult(ToolUseGuardDecision Decision, string Observation, int IdenticalFailureCount); + +public sealed class ToolUseGuard(int sameFailureLimit = 5) +{ + private readonly Dictionary _failures = new(StringComparer.Ordinal); + public int SameFailureLimit { get; } = sameFailureLimit > 0 ? sameFailureLimit : throw new ArgumentOutOfRangeException(nameof(sameFailureLimit)); + + public ToolUseGuardResult Evaluate(bool requiresExternalEffect, bool hadToolCall, bool hasExternalEffectEvidence, string? failureFingerprint = null) + { + if (!requiresExternalEffect) return new(ToolUseGuardDecision.Complete, string.Empty, 0); + if (hasExternalEffectEvidence) return new(ToolUseGuardDecision.Complete, string.Empty, 0); + string fingerprint = string.IsNullOrWhiteSpace(failureFingerprint) ? "missing-external-effect" : failureFingerprint; + int count = _failures.TryGetValue(fingerprint, out int prior) ? prior + 1 : 1; + _failures[fingerprint] = count; + if (count >= SameFailureLimit) return new(ToolUseGuardDecision.Fail, "Required external effect was not completed after repeated identical failures.", count); + return new(ToolUseGuardDecision.Retry, "No verified external effect occurred; perform the required operation using an internal tool.", count); + } +} diff --git a/src/Agent.Core/Tools/ToolValidationException.cs b/src/Agent.Core/Tools/ToolValidationException.cs new file mode 100644 index 0000000..a99472c --- /dev/null +++ b/src/Agent.Core/Tools/ToolValidationException.cs @@ -0,0 +1,3 @@ +namespace Agent.Core.Tools; + +public sealed class ToolValidationException(string message) : InvalidOperationException(message); -- cgit v1.2.3