diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-11 21:53:57 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-11 21:53:57 +0900 |
| commit | 7c9794f8d4d05074b96bb525d19db4ee1f6b3e95 (patch) | |
| tree | cb8961ee170d9f6874513e8b1a99b06855bb0707 /src | |
| parent | 6c3458a5ec37a5b69957ad17c84de58984698f25 (diff) | |
Complete runtime diagnostics and verification coverage
Diffstat (limited to 'src')
| -rw-r--r-- | src/Agent.App/Agent.App.csproj | 3 | ||||
| -rw-r--r-- | src/Agent.App/Program.cs | 28 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/AgentRuntime.cs | 50 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/PlannerService.cs | 1 | ||||
| -rw-r--r-- | src/Agent.Core/Tools/InternalToolDispatcher.cs | 3 | ||||
| -rw-r--r-- | src/Agent.Core/Tools/ToolPolicy.cs | 7 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/ExecutablePolicy.cs | 6 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/PathPolicy.cs | 13 | ||||
| -rw-r--r-- | src/Agent.Windows/Tools/WindowsToolBackend.cs | 17 |
9 files changed, 107 insertions, 21 deletions
diff --git a/src/Agent.App/Agent.App.csproj b/src/Agent.App/Agent.App.csproj index 4059624..17b7bc9 100644 --- a/src/Agent.App/Agent.App.csproj +++ b/src/Agent.App/Agent.App.csproj @@ -11,6 +11,9 @@ <ProjectReference Include="..\Agent.Windows\Agent.Windows.csproj" /> </ItemGroup> <ItemGroup> + <InternalsVisibleTo Include="Agent.Core.Tests" /> + </ItemGroup> + <ItemGroup> <PackageReference Include="Microsoft.Extensions.Hosting" /> <PackageReference Include="YamlDotNet" /> </ItemGroup> diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs index 6f3485f..3d2d2db 100644 --- a/src/Agent.App/Program.cs +++ b/src/Agent.App/Program.cs @@ -110,7 +110,7 @@ internal static class Program } catch (Exception ex) when (ex is CommandLineException or ConfigurationException or LocaleException or IOException or UnauthorizedAccessException) { - Console.Error.WriteLine($"agent-mcp: {ex.Message}"); + Console.Error.WriteLine($"agent-mcp: {FormatStartupError(args, ex)}"); return 2; } catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) @@ -120,6 +120,30 @@ internal static class Program } } + internal static string FormatStartupError(string[] args, Exception error) + { + string language = "en"; + for (int i = 0; i + 1 < args.Length; i++) + if (args[i] == "--lang") language = args[i + 1]; + try + { + string directory = Path.Combine(AppContext.BaseDirectory, "config", "locales"); + LocaleCatalog locale = LocaleCatalog.Load(directory, language, "en"); + string key = error switch + { + CommandLineException => "cli.invalid", + ConfigurationException => "config.invalid", + LocaleException => "locale.invalid", + _ => "app.io_error" + }; + return locale.Format(key, new Dictionary<string, object?> { ["message"] = error.Message }); + } + catch + { + return error.Message; + } + } + private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, string baseDirectory, string logDirectory, SessionStore? sessionStore = null) { string stateDirectory = Path.Combine(baseDirectory, "state"); @@ -155,7 +179,7 @@ internal static class Program } } - private static AgentRuntimeSettings CreateRuntimeSettings(AgentConfiguration configuration, string userPrompt, string language, + internal static AgentRuntimeSettings CreateRuntimeSettings(AgentConfiguration configuration, string userPrompt, string language, IReadOnlyList<Agent.Core.Skills.AgentSkill> skills, LocaleCatalog locale) => new( userPrompt, language, diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs index 8cf1ddb..bc9fc5e 100644 --- a/src/Agent.Core/Runtime/AgentRuntime.cs +++ b/src/Agent.Core/Runtime/AgentRuntime.cs @@ -75,8 +75,11 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag 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.TryGet(taskId, null, out TaskSnapshot? failedSnapshot); + string baseResult = failedSnapshot?.LastErrorCode is not null and not "context_summary_failed" + ? failedSnapshot.LastError ?? ex.Message + : ex.Message; + string result = cleanupFailure is null ? baseResult : $"{baseResult} {cleanupFailure}"; string errorCode = cleanupFailure is not null ? "cleanup_failed" : failedSnapshot?.LastErrorCode ?? "execution_failed"; string errorMessage = cleanupFailure is not null ? result : failedSnapshot?.LastError ?? result; registry.UpdateDiagnostics(taskId, null, null, errorCode, errorMessage); @@ -122,11 +125,20 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag catch (PolicyDeniedException ex) { graph.SetState(next.Id, PlannerTaskState.Failed); - registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "policy_denied", ex.Message); + string localizedDenial = LocalizePolicyDenial(ex); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, ex.ErrorCode, localizedDenial); if (!denyFingerprints.Add(ex.Fingerprint)) - throw new InvalidOperationException($"The same sandbox policy denial occurred again: {ex.Message}"); + { + string repeated = Message("runtime.policy_repeated", $"The same sandbox policy denial occurred again: {ex.Message}"); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "policy.repeated", repeated); + throw new InvalidOperationException(repeated); + } if (counters.Replans >= maxReplans) - throw new InvalidOperationException($"Sandbox policy denial remained unresolved after {maxReplans} replans: {ex.Message}"); + { + string exhausted = Message("runtime.policy_replan_exhausted", $"Sandbox policy denial remained unresolved after {maxReplans} replans: {ex.Message}", maxReplans.ToString(System.Globalization.CultureInfo.InvariantCulture)); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "policy.replan_exhausted", exhausted); + throw new InvalidOperationException(exhausted); + } string previousSignature = GraphSignature(graph); counters.Replans++; @@ -134,7 +146,11 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag graph = await _planner.ReplanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, ex.Message, (phase, messages, result, error) => RecordLlmAttempt(task, counters, phase, messages, result, error), cancellationToken).ConfigureAwait(false); if (string.Equals(previousSignature, GraphSignature(graph), StringComparison.Ordinal)) - throw new InvalidOperationException("Replan did not change the incomplete TaskGraph."); + { + string unchanged = Message("runtime.replan_unchanged", "Replan did not change the incomplete TaskGraph."); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "planning.replan_unchanged", unchanged); + throw new InvalidOperationException(unchanged); + } if (!registry.Transition(taskId, AgentTaskState.Running, "replan")) throw new InvalidOperationException("Task state could not return to running after replan."); UpdateTaskProgress(taskId, graph, null, counters); @@ -271,7 +287,9 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag _observer.ToolResult(callContext, call.Function.Name, toolResult); if (!toolResult.Success && !toolResult.RetryAllowed && !isMetadataTool) - throw new PolicyDeniedException(toolResult.FailureFingerprint ?? $"policy:{call.Function.Name}:{toolResult.Output}", toolResult.Output); + throw new PolicyDeniedException(toolResult.FailureFingerprint ?? $"policy:{call.Function.Name}:{toolResult.Output}", toolResult.Output, + toolResult.ErrorCode ?? "policy_denied", toolResult.ErrorCategory, toolResult.TargetPath, + toolResult.RequiredPermission, toolResult.Resolution); failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint; messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name)); } @@ -454,9 +472,27 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag return detail is null ? locale.Get(key) : locale.Format(key, new Dictionary<string, object?> { ["message"] = detail }); } - private sealed class PolicyDeniedException(string fingerprint, string message) : Exception(message) + private sealed class PolicyDeniedException(string fingerprint, string message, string errorCode, string? errorCategory, + string? targetPath, string? requiredPermission, string? resolution) : Exception(message) { public string Fingerprint { get; } = fingerprint; + public string ErrorCode { get; } = errorCode; + public string? ErrorCategory { get; } = errorCategory; + public string? TargetPath { get; } = targetPath; + public string? RequiredPermission { get; } = requiredPermission; + public string? Resolution { get; } = resolution; + } + + private string LocalizePolicyDenial(PolicyDeniedException error) + { + if (locale is null) return error.Message; + return locale.Format("runtime.policy_denied", new Dictionary<string, object?> + { + ["code"] = error.ErrorCode, + ["path"] = error.TargetPath ?? "-", + ["permission"] = error.RequiredPermission ?? "-", + ["resolution"] = error.Resolution ?? "-" + }); } private IReadOnlyList<AgentSkill> ParseSelectedSkills(string content) diff --git a/src/Agent.Core/Runtime/PlannerService.cs b/src/Agent.Core/Runtime/PlannerService.cs index 99569e4..7d8c3e1 100644 --- a/src/Agent.Core/Runtime/PlannerService.cs +++ b/src/Agent.Core/Runtime/PlannerService.cs @@ -288,6 +288,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se For pure analysis or informational tasks, requires_external_effect should be false. For tasks that modify files, run processes, or produce verifiable side effects, requires_external_effect should be true. + Read-only operations (file_read, file_search, and dir list) do not create an external effect and must use requires_external_effect=false. A single task is valid for simple requests. """; diff --git a/src/Agent.Core/Tools/InternalToolDispatcher.cs b/src/Agent.Core/Tools/InternalToolDispatcher.cs index 0ae078c..c88b92c 100644 --- a/src/Agent.Core/Tools/InternalToolDispatcher.cs +++ b/src/Agent.Core/Tools/InternalToolDispatcher.cs @@ -11,7 +11,8 @@ public sealed class InternalToolDispatcher(IToolPolicyEngine policy, IInternalTo { InternalToolValidator.Validate(toolName, arguments); ToolPolicyDecision decision = policy.Evaluate(toolName, arguments); - if (!decision.Allowed) return new(false, decision.Reason, false, $"policy:{toolName}:{decision.Reason}", RetryAllowed: false); + if (!decision.Allowed) return new(false, decision.Reason, false, $"policy:{toolName}:{decision.Reason}", RetryAllowed: false, + ErrorCode: "policy.tool_denied", ErrorCategory: "policy", Resolution: "Review the enabled Internal Tool policy."); return await backend.ExecuteAsync(taskId, toolName, arguments, cancellationToken).ConfigureAwait(false); } } diff --git a/src/Agent.Core/Tools/ToolPolicy.cs b/src/Agent.Core/Tools/ToolPolicy.cs index cefca8d..66f63ab 100644 --- a/src/Agent.Core/Tools/ToolPolicy.cs +++ b/src/Agent.Core/Tools/ToolPolicy.cs @@ -25,7 +25,12 @@ public sealed record ToolExecutionResult( string Output, bool ExternalEffect, string? FailureFingerprint = null, - bool RetryAllowed = true); + bool RetryAllowed = true, + string? ErrorCode = null, + string? ErrorCategory = null, + string? TargetPath = null, + string? RequiredPermission = null, + string? Resolution = null); public sealed class DenyByDefaultPolicy(IReadOnlySet<string>? allowedTools = null) : IToolPolicyEngine { diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs index 1f36164..6c72724 100644 --- a/src/Agent.Windows/Security/ExecutablePolicy.cs +++ b/src/Agent.Windows/Security/ExecutablePolicy.cs @@ -13,12 +13,12 @@ public sealed class ExecutablePolicy(IEnumerable<ExecutableApproval> approvals, 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: '{canonical}'. Add its canonical path and SHA-256 to sandbox.process.approvals."); + if (!allowShell && IsShell(canonical)) throw new SandboxPolicyException("process.shell_denied", "Shell execution is disabled.", "process", canonical, "execute", "sandbox.process.allow_shell"); + if (!_approvals.TryGetValue(canonical, out string? approvedHash)) throw new SandboxPolicyException("process.path_denied", $"Executable path is not approved: '{canonical}'. Add its canonical path and SHA-256 to sandbox.process.approvals.", "process", canonical, "execute", "sandbox.process.approvals"); using FileStream stream = new(canonical, FileMode.Open, FileAccess.Read, FileShare.Read); string actualHash = Convert.ToHexString(SHA256.HashData(stream)); if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(approvedHash), Convert.FromHexString(actualHash))) - throw new SandboxPolicyException("process.hash_denied", $"Executable SHA-256 does not match its approval: '{canonical}'. Recalculate SHA-256 and update sandbox.process.approvals."); + throw new SandboxPolicyException("process.hash_denied", $"Executable SHA-256 does not match its approval: '{canonical}'. Recalculate SHA-256 and update sandbox.process.approvals.", "process", canonical, "execute", "sandbox.process.approvals"); return new(canonical, actualHash); } diff --git a/src/Agent.Windows/Security/PathPolicy.cs b/src/Agent.Windows/Security/PathPolicy.cs index 4f6eec4..3de4804 100644 --- a/src/Agent.Windows/Security/PathPolicy.cs +++ b/src/Agent.Windows/Security/PathPolicy.cs @@ -18,7 +18,8 @@ public sealed class PathPolicy(IEnumerable<FilePolicyEntry> entries) public void Demand(string path, FileCapability required) { if (!IsAllowed(path, required)) - throw new SandboxPolicyException("policy.deny", $"'{path}' is not approved for {required}. Add the canonical path to sandbox.filesystem.{required.ToString().ToLowerInvariant()} and restart the server."); + throw new SandboxPolicyException("policy.deny", $"'{path}' is not approved for {required}. Add the canonical path to sandbox.filesystem.{required.ToString().ToLowerInvariant()} and restart the server.", + "filesystem", Path.GetFullPath(path), required.ToString().ToLowerInvariant(), $"sandbox.filesystem.{required.ToString().ToLowerInvariant()}"); } private static bool IsAtOrBelow(string candidate, string root) => @@ -43,7 +44,8 @@ public static class CanonicalPath { FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current); string? target = info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? info.LinkTarget; - throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: component='{current}'. Approve the resolved canonical target{(string.IsNullOrWhiteSpace(target) ? string.Empty : $" '{Path.GetFullPath(target)}'")} instead."); + throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: component='{current}'. Approve the resolved canonical target{(string.IsNullOrWhiteSpace(target) ? string.Empty : $" '{Path.GetFullPath(target)}'")} instead.", + "filesystem", current, null, string.IsNullOrWhiteSpace(target) ? "sandbox.filesystem" : Path.GetFullPath(target)); } } if (requireExisting && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Approved path does not exist.", fullPath); @@ -51,7 +53,12 @@ public static class CanonicalPath } } -public sealed class SandboxPolicyException(string code, string message) : UnauthorizedAccessException(message) +public sealed class SandboxPolicyException(string code, string message, string category = "policy", string? targetPath = null, + string? requiredPermission = null, string? resolution = null) : UnauthorizedAccessException(message) { public string Code { get; } = code; + public string Category { get; } = category; + public string? TargetPath { get; } = targetPath; + public string? RequiredPermission { get; } = requiredPermission; + public string? Resolution { get; } = resolution; } diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs index 5c1fe49..c432894 100644 --- a/src/Agent.Windows/Tools/WindowsToolBackend.cs +++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs @@ -93,7 +93,9 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string> { ["tool"] = toolName, ["message"] = ex.Message }, SessionId: context.SessionId, ErrorCode: ex.Code)); - return Task.FromResult(new ToolExecutionResult(false, ex.Message, false, $"{toolName}:{ex.Code}:{ex.Message}", RetryAllowed: false)); + return Task.FromResult(new ToolExecutionResult(false, ex.Message, false, $"{toolName}:{ex.Code}:{ex.Message}", RetryAllowed: false, + ErrorCode: ex.Code, ErrorCategory: ex.Category, TargetPath: ex.TargetPath, + RequiredPermission: ex.RequiredPermission, Resolution: ex.Resolution)); } } @@ -255,9 +257,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit 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() : settings.ProcessTimeoutSeconds; - if (seconds > settings.ProcessTimeoutSeconds) - throw new ToolValidationException($"timeout_seconds exceeds configured sandbox.resources.process_timeout_seconds ({settings.ProcessTimeoutSeconds})."); + int seconds = ResolveProcessTimeout(arguments, settings.ProcessTimeoutSeconds); string? stdin = arguments.TryGetProperty("stdin", out JsonElement stdinElement) && stdinElement.ValueKind == JsonValueKind.String ? stdinElement.GetString() : null; RestrictedProcessResult result = new RestrictedProcessLauncher(_executables, audit, AppContainerProfile.EnsureFixed().Sid, settings.JobLimits) .Run(taskId, executable, args, cwd, TimeSpan.FromSeconds(seconds), stdin, context.SessionId); @@ -266,6 +266,15 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit result.ExitCode == 0 ? null : $"process:{executable}:{result.ExitCode}"); } + internal static int ResolveProcessTimeout(JsonElement arguments, int configuredTimeoutSeconds) + { + int seconds = arguments.TryGetProperty("timeout_seconds", out JsonElement timeout) ? timeout.GetInt32() : configuredTimeoutSeconds; + if (seconds <= 0) throw new ToolValidationException("timeout_seconds must be positive."); + if (seconds > configuredTimeoutSeconds) + throw new ToolValidationException($"timeout_seconds exceeds configured sandbox.resources.process_timeout_seconds ({configuredTimeoutSeconds})."); + return seconds; + } + private async Task CleanupAsync(Guid taskId, TaskContext context) { List<Exception> failures = []; |
