diff options
Diffstat (limited to 'src/Agent.Windows/Tools')
| -rw-r--r-- | src/Agent.Windows/Tools/WindowsToolBackend.cs | 160 |
1 files changed, 106 insertions, 54 deletions
diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs index ec122e9..388b145 100644 --- a/src/Agent.Windows/Tools/WindowsToolBackend.cs +++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs @@ -4,6 +4,7 @@ using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using Agent.Core.Tools; +using Agent.Admin.Client; using Agent.Windows.Network; using Agent.Windows.Recovery; using Agent.Windows.Security; @@ -28,56 +29,31 @@ public sealed record EffectiveSandboxPolicy( string ResourceProfile = "normal", IReadOnlyList<string>? BaselineCapabilities = null); -public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurityAuditSink audit, ITaskUpdateHandler? taskUpdates = null) : IInternalToolBackend +public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurityAuditSink audit, ITaskUpdateHandler? taskUpdates = null, + IElevatedNetworkSessionFactory? elevatedNetwork = null) : IInternalToolBackend { private readonly ConcurrentDictionary<Guid, TaskContext> _contexts = new(); private readonly PathPolicy _paths = new(settings.Files); private readonly EntryExecutablePolicy _entryExecutables = new(settings.EntryExecutables, settings.RuntimeRoots, settings.ArtifactRoots); private readonly ITaskUpdateHandler _taskUpdates = taskUpdates ?? new TaskGraphToolHandler(); + private readonly IElevatedNetworkSessionFactory _elevatedNetwork = elevatedNetwork ?? + new ElevatedNetworkSessionFactory(Path.Combine(AppContext.BaseDirectory, "admin", "agent-mcp-admin.exe")); + private bool _cleanupFailed; public async Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) { if (settings.BaselineCapabilities is not null && !settings.BaselineCapabilities.SequenceEqual(["registryRead", "lpacInstrumentation"], StringComparer.Ordinal)) throw new InvalidOperationException("Effective Sandbox Policy baseline capabilities do not match the fixed LPAC baseline."); + if (_cleanupFailed) throw new InvalidOperationException("A prior Task Resource cleanup failed; restart is required for Recovery."); 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, sessionId); + var context = new TaskContext(journal, acl, profile, sessionId); _taskUpdates.BeginTask(taskId, sessionId); try { - string[] osRuntimeRoots = settings.Files - .Where(static entry => entry.Origin.Split(',').Contains("os_runtime_root", StringComparer.Ordinal)) - .Select(static entry => ResolvedPath.Resolve(entry.Path, false)) - .ToArray(); - foreach ((string path, FileCapability capabilities, bool osRuntimeRoot) in settings.Files - .GroupBy(static entry => ResolvedPath.Resolve(entry.Path, false), StringComparer.OrdinalIgnoreCase) - .Select(static group => (group.Key, - group.Aggregate(FileCapability.None, static (value, item) => value | item.Capabilities), - group.All(static item => item.Origin == "os_runtime_root")))) - { - // System32 has a protected OS-owned DACL. Its existing LPAC access is verified by - // real process-tree tests; changing or unprotecting that DACL is forbidden. - if (osRuntimeRoot) continue; - // Approved System32 Entry files are nested under the same protected OS Runtime Root. - // Their existing LPAC access is used; a separate file-level DACL rewrite is forbidden. - if (File.Exists(path) && capabilities == (FileCapability.Read | FileCapability.Execute) && - osRuntimeRoots.Any(root => IsBelow(path, root))) continue; - // Executable-only entries may already grant AppContainer execute through their system ACL. - // CreateProcess remains constrained by LPAC access plus Resolved Path/SHA-256 Entry approval. - if (File.Exists(path) && capabilities == FileCapability.Execute) continue; - // A single inheritable ACE on an approved directory covers existing and newly-created - // descendants. Snapshotting and rewriting every child ACL is both redundant and - // prohibitively expensive for runtime trees such as Python installations. - context.Acls.Add(acl.Grant(taskId, path, capabilities)); - } - IReadOnlyList<NetworkEndpoint> allowances = settings.NetworkAccess != "none" - ? await NetworkAllowanceSnapshot.ResolveAsync(settings.NetworkAllowances, cancellationToken).ConfigureAwait(false) - : []; - context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit, sessionId, - enableLoopback: settings.NetworkAccess != "none"); if (!_contexts.TryAdd(taskId, context)) throw new InvalidOperationException("Task context already exists."); audit.Write(new(DateTimeOffset.UtcNow, taskId, "task.start", "running", new Dictionary<string, string>(), SessionId: sessionId)); } @@ -89,7 +65,7 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit } } - public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) + public async Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) { TaskContext context = Get(taskId); audit.Write(new(DateTimeOffset.UtcNow, taskId, "tool.call", "attempted", @@ -98,12 +74,12 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit { 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(context, arguments, cancellationToken)), - "task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)), - "dir" => Task.FromResult(DirectoryOp(context, arguments)), + "file_search" => Search(arguments), + "file_read" => Read(arguments), + "file_edit" => Edit(context, arguments), + "process_run" => await RunProcessAsync(taskId, context, arguments, cancellationToken).ConfigureAwait(false), + "task_update" => _taskUpdates.Execute(taskId, arguments), + "dir" => DirectoryOp(context, arguments), _ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.") }; } @@ -112,16 +88,23 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy 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 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)); + RequiredPermission: ex.RequiredPermission, Resolution: ex.Resolution); + } + catch (AdminOperationException ex) + { + audit.Write(new(DateTimeOffset.UtcNow, taskId, ex.Code == "sandbox.ipc_rejected" ? "sandbox.ipc.reject" : "sandbox.elevation.deny", + "failed", new Dictionary<string, string> { ["message"] = ex.Message }, SessionId: context.SessionId, ErrorCode: ex.Code)); + return new ToolExecutionResult(false, ex.Message, false, $"{toolName}:{ex.Code}", RetryAllowed: false, + ErrorCode: ex.Code, ErrorCategory: "platform", Resolution: "Approve the Windows elevation prompt and retry the Agent Task."); } catch (Exception ex) when (ex is ToolValidationException or JsonException or InvalidOperationException) { - return Task.FromResult(new ToolExecutionResult(false, ex.Message, false, + return new ToolExecutionResult(false, ex.Message, false, $"{toolName}:tool.input_invalid:{ex.Message}", RetryAllowed: true, ErrorCode: "tool.input_invalid", ErrorCategory: "tool", - Resolution: "Correct the Tool arguments using the published schema and retry.")); + Resolution: "Correct the Tool arguments using the published schema and retry."); } } @@ -273,18 +256,23 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit throw new ToolValidationException($"Unsupported dir operation '{operation}'."); } - private ToolExecutionResult RunProcess(TaskContext context, JsonElement arguments, CancellationToken cancellationToken) + private async Task<ToolExecutionResult> RunProcessAsync(Guid taskId, TaskContext context, JsonElement arguments, CancellationToken cancellationToken) { - Guid taskId = context.Journal.TaskId; string executable = arguments.GetProperty("executable").GetString()!; string cwd = arguments.GetProperty("cwd").GetString()!; _paths.Demand(cwd, FileCapability.Read); string[] args = arguments.GetProperty("args").EnumerateArray().Select(static item => item.GetString()!).ToArray(); + _ = _entryExecutables.VerifyEntry(executable, cwd); string? managedEntryPoint = _entryExecutables.ManagedEntryPoint(executable, args, cwd); - if (managedEntryPoint is not null) _paths.Demand(managedEntryPoint, FileCapability.Execute); + if (managedEntryPoint is not null) + { + _paths.Demand(managedEntryPoint, FileCapability.Execute); + _entryExecutables.VerifyManagedEntryPoint(managedEntryPoint); + } + await EnsureProcessResourcesAsync(taskId, context, cancellationToken).ConfigureAwait(false); 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(_entryExecutables, audit, AppContainerProfile.EnsureFixed().Sid, settings.JobLimits, + RestrictedProcessResult result = new RestrictedProcessLauncher(_entryExecutables, audit, context.Profile.Sid, settings.JobLimits, allowNetwork: settings.NetworkAccess != "none") .Run(taskId, executable, args, cwd, TimeSpan.FromSeconds(seconds), stdin, context.SessionId, cancellationToken); string output = $"Process exited with code {result.ExitCode} (PID {result.ProcessId}).\nstdout:\n{result.StandardOutput}\nstderr:\n{result.StandardError}"; @@ -292,6 +280,66 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit result.ExitCode == 0 ? null : $"process:{executable}:{result.ExitCode}"); } + private async Task EnsureProcessResourcesAsync(Guid taskId, TaskContext context, CancellationToken cancellationToken) + { + if (context.ProcessResourcesApplied) return; + await context.ResourceGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (context.ProcessResourcesApplied) return; + IReadOnlyList<NetworkEndpoint> allowances = settings.NetworkAccess != "none" + ? await NetworkAllowanceSnapshot.ResolveAsync(settings.NetworkAllowances, cancellationToken).ConfigureAwait(false) + : []; + IReadOnlyList<(string Path, FileCapability Capabilities)> aclTargets = ProcessAclTargets(); + foreach ((string path, _) in aclTargets) TaskAclManager.DemandWriteDac(path); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "sandbox.elevation.request", "requested", + new Dictionary<string, string> { ["reason"] = "network_task_resource" }, SessionId: context.SessionId)); + IElevatedNetworkSession? elevated = null; + try + { + elevated = await _elevatedNetwork.StartAsync(taskId, cancellationToken).ConfigureAwait(false); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "sandbox.elevation.success", "connected", + new Dictionary<string, string> { ["protocol"] = AdminProtocol.Version.ToString() }, SessionId: context.SessionId)); + ApplyProcessAcls(taskId, context, aclTargets); + context.Network = await TaskNetworkPolicy.ApplyAsync(elevated, context.Journal, context.Profile.Sid, + settings.NetworkAccess, allowances, audit, context.SessionId, cancellationToken).ConfigureAwait(false); + elevated = null; // ownership transferred to TaskNetworkPolicy + context.ProcessResourcesApplied = true; + } + finally + { + if (elevated is not null) await elevated.DisposeAsync().ConfigureAwait(false); + } + } + finally { context.ResourceGate.Release(); } + } + + private IReadOnlyList<(string Path, FileCapability Capabilities)> ProcessAclTargets() + { + var targets = new List<(string Path, FileCapability Capabilities)>(); + foreach ((string path, FileCapability capabilities, bool osRuntimeRoot) in settings.Files + .GroupBy(static entry => ResolvedPath.Resolve(entry.Path, false), StringComparer.OrdinalIgnoreCase) + .Select(static group => (group.Key, + group.Aggregate(FileCapability.None, static (value, item) => value | item.Capabilities), + group.All(static item => item.Origin == "os_runtime_root")))) + { + if (osRuntimeRoot) continue; + // Entry files are identity gates, not ACL roots. Runtime/Workspace directory ACLs + // provide inherited LPAC access where configured; OS/Program Files entries retain + // their existing protected ACLs and are validated by the real LPAC launch test. + if (File.Exists(path)) continue; + targets.Add((path, capabilities)); + } + return targets; + } + + private static void ApplyProcessAcls(Guid taskId, TaskContext context, + IReadOnlyList<(string Path, FileCapability Capabilities)> targets) + { + foreach ((string path, FileCapability capabilities) in targets) + context.Acls.Add(context.Acl.Grant(taskId, path, capabilities)); + } + internal static int ResolveProcessTimeout(JsonElement arguments, int configuredTimeoutSeconds) { int seconds = arguments.TryGetProperty("timeout_seconds", out JsonElement timeout) ? timeout.GetInt32() : configuredTimeoutSeconds; @@ -304,17 +352,18 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit private async Task CleanupAsync(Guid taskId, TaskContext context) { List<Exception> failures = []; - try { context.Network?.Dispose(); } catch (Exception ex) { failures.Add(ex); } + if (context.Network is not null) + try { await context.Network.DisposeAsync().ConfigureAwait(false); } 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); } if (failures.Count == 0) context.Journal.CompleteAndDelete(); - await Task.CompletedTask; - if (failures.Count > 0) throw new AggregateException("Task resource cleanup failed.", failures); + if (failures.Count > 0) + { + _cleanupFailed = true; + 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 bool IsBelow(string child, string parent) => - child.StartsWith(parent.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, - StringComparison.OrdinalIgnoreCase); private static ToolExecutionResult Success(string output, bool externalEffect = false) => new(true, output, externalEffect); private static bool IsTextMatch(string path, Func<string, bool> predicate) { @@ -346,12 +395,15 @@ public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurit } } - private sealed class TaskContext(TaskResourceJournal journal, TaskAclManager acl, Guid sessionId) + private sealed class TaskContext(TaskResourceJournal journal, TaskAclManager acl, AppContainerProfile profile, Guid sessionId) { public TaskResourceJournal Journal { get; } = journal; public Guid SessionId { get; } = sessionId; + public AppContainerProfile Profile { get; } = profile; public TaskAclManager Acl { get; } = acl; public List<AclSnapshot> Acls { get; } = []; public TaskNetworkPolicy? Network { get; set; } + public SemaphoreSlim ResourceGate { get; } = new(1, 1); + public bool ProcessResourcesApplied { get; set; } } } |
