summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Tools
diff options
context:
space:
mode:
Diffstat (limited to 'src/Agent.Windows/Tools')
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs160
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; }
}
}