From 7c9794f8d4d05074b96bb525d19db4ee1f6b3e95 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Tue, 11 Aug 2026 21:53:57 +0900 Subject: Complete runtime diagnostics and verification coverage --- src/Agent.Windows/Security/ExecutablePolicy.cs | 6 +++--- src/Agent.Windows/Security/PathPolicy.cs | 13 ++++++++++--- src/Agent.Windows/Tools/WindowsToolBackend.cs | 17 +++++++++++++---- 3 files changed, 26 insertions(+), 10 deletions(-) (limited to 'src/Agent.Windows') 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 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 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 { ["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 failures = []; -- cgit v1.2.3