summaryrefslogtreecommitdiff
path: root/src/Agent.Windows
diff options
context:
space:
mode:
Diffstat (limited to 'src/Agent.Windows')
-rw-r--r--src/Agent.Windows/Network/TaskNetworkPolicy.cs18
-rw-r--r--src/Agent.Windows/Recovery/RecoveryManager.cs3
-rw-r--r--src/Agent.Windows/Security/ExecutablePolicy.cs4
-rw-r--r--src/Agent.Windows/Security/PathPolicy.cs9
-rw-r--r--src/Agent.Windows/Security/RestrictedProcessLauncher.cs15
-rw-r--r--src/Agent.Windows/Security/SecurityAudit.cs6
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs46
7 files changed, 68 insertions, 33 deletions
diff --git a/src/Agent.Windows/Network/TaskNetworkPolicy.cs b/src/Agent.Windows/Network/TaskNetworkPolicy.cs
index 59b2602..bc4d291 100644
--- a/src/Agent.Windows/Network/TaskNetworkPolicy.cs
+++ b/src/Agent.Windows/Network/TaskNetworkPolicy.cs
@@ -13,16 +13,18 @@ public sealed class TaskNetworkPolicy : IDisposable
private readonly SecurityIdentifier _appContainerSid;
private readonly Guid _loopbackEntryId;
private readonly bool _loopbackAdded;
+ private readonly Guid? _sessionId;
private bool _disposed;
private TaskNetworkPolicy(WfpDynamicSession session, TaskResourceJournal journal, ISecurityAuditSink audit, Guid entryId,
- SecurityIdentifier appContainerSid, Guid loopbackEntryId, bool loopbackAdded)
+ SecurityIdentifier appContainerSid, Guid loopbackEntryId, bool loopbackAdded, Guid? sessionId)
{
_session = session; _journal = journal; _audit = audit; _entryId = entryId;
_appContainerSid = appContainerSid; _loopbackEntryId = loopbackEntryId; _loopbackAdded = loopbackAdded;
+ _sessionId = sessionId;
}
- public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> allowances, ISecurityAuditSink audit)
+ public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> allowances, ISecurityAuditSink audit, Guid? sessionId = null)
{
bool originallyExempt = LoopbackExemption.Contains(appContainerSid);
Guid loopbackEntryId = journal.Prepare("network.loopback_exemption", appContainerSid.Value,
@@ -39,13 +41,13 @@ public sealed class TaskNetworkPolicy : IDisposable
journal.MarkApplied(loopbackEntryId, new Dictionary<string, string> { ["added"] = loopbackAdded.ToString() });
IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(appContainerSid, allowances);
journal.MarkApplied(entryId, new Dictionary<string, string> { ["filter_ids"] = string.Join(',', ids) });
- audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "wfp.apply", "applied", new Dictionary<string, string>
+ audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.rule.apply", "applied", new Dictionary<string, string>
{
["ipv4_ipv6_default_block"] = "true", ["filter_count"] = ids.Count.ToString(), ["session"] = "dynamic"
- }));
+ }, SessionId: sessionId));
audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", loopbackAdded ? "added" : "preserved",
- new Dictionary<string, string> { ["app_container_sid"] = appContainerSid.Value }));
- return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded);
+ new Dictionary<string, string> { ["app_container_sid"] = appContainerSid.Value }, SessionId: sessionId));
+ return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded, sessionId);
}
catch
{
@@ -65,9 +67,9 @@ public sealed class TaskNetworkPolicy : IDisposable
_journal.MarkCleaned(_entryId);
if (_loopbackAdded) LoopbackExemption.Remove(_appContainerSid);
_journal.MarkCleaned(_loopbackEntryId);
- _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "wfp.remove", "removed", new Dictionary<string, string> { ["residual_filters"] = "0" }));
+ _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.rule.remove", "removed", new Dictionary<string, string> { ["residual_filters"] = "0" }, SessionId: _sessionId));
_audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed",
- new Dictionary<string, string> { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" }));
+ new Dictionary<string, string> { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" }, SessionId: _sessionId));
_disposed = true;
}
}
diff --git a/src/Agent.Windows/Recovery/RecoveryManager.cs b/src/Agent.Windows/Recovery/RecoveryManager.cs
index c9b28bf..9dfeef9 100644
--- a/src/Agent.Windows/Recovery/RecoveryManager.cs
+++ b/src/Agent.Windows/Recovery/RecoveryManager.cs
@@ -24,6 +24,7 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink
try
{
TaskResourceJournal journal = TaskResourceJournal.Open(path);
+ audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.start", "started", new Dictionary<string, string>()));
foreach (JournalEntry entry in journal.Entries.Reverse())
{
if (entry.State == JournalEntryState.Cleaned) continue;
@@ -33,7 +34,7 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink
resources++;
}
journal.CompleteAndDelete();
- audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.complete", "clean", new Dictionary<string, string>()));
+ audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.end", "clean", new Dictionary<string, string>()));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException)
{
diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs
index 3d20bfa..1f36164 100644
--- a/src/Agent.Windows/Security/ExecutablePolicy.cs
+++ b/src/Agent.Windows/Security/ExecutablePolicy.cs
@@ -14,11 +14,11 @@ public sealed class ExecutablePolicy(IEnumerable<ExecutableApproval> approvals,
{
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.");
+ 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.");
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.");
+ throw new SandboxPolicyException("process.hash_denied", $"Executable SHA-256 does not match its approval: '{canonical}'. Recalculate SHA-256 and update sandbox.process.approvals.");
return new(canonical, actualHash);
}
diff --git a/src/Agent.Windows/Security/PathPolicy.cs b/src/Agent.Windows/Security/PathPolicy.cs
index 32a949c..4f6eec4 100644
--- a/src/Agent.Windows/Security/PathPolicy.cs
+++ b/src/Agent.Windows/Security/PathPolicy.cs
@@ -17,7 +17,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}.");
+ 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.");
}
private static bool IsAtOrBelow(string candidate, string root) =>
@@ -39,7 +40,11 @@ public static class CanonicalPath
current = Path.Combine(current, segment);
if (!File.Exists(current) && !Directory.Exists(current)) continue;
if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
- throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: '{current}'.");
+ {
+ 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.");
+ }
}
if (requireExisting && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Approved path does not exist.", fullPath);
return Path.TrimEndingDirectorySeparator(fullPath);
diff --git a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
index 0786150..687af94 100644
--- a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
+++ b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
@@ -19,7 +19,8 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
private const uint ProcessCreationChildProcessRestricted = 0x1;
private readonly SandboxJobLimits _jobLimits = jobLimits ?? new();
- public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout, string? standardInput = null)
+ public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout,
+ string? standardInput = null, Guid? sessionId = null)
{
VerifiedExecutable verified;
VerifiedExecutable? managedEntry = null;
@@ -31,7 +32,7 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
}
catch (SandboxPolicyException ex)
{
- audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string> { ["code"] = ex.Code, ["executable"] = executable }));
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string> { ["code"] = ex.Code, ["executable"] = executable }, SessionId: sessionId, ErrorCode: ex.Code));
throw;
}
@@ -142,7 +143,7 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string>
{
["code"] = ex.Code, ["executable"] = executable, ["stage"] = "post_create_pre_resume"
- }));
+ }, SessionId: sessionId, ErrorCode: ex.Code));
throw;
}
if (PInvoke.ResumeThread(process.hThread) == uint.MaxValue) throw LastError("ResumeThread");
@@ -157,23 +158,23 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
processAudit["managed_entry_path"] = managedEntry.CanonicalPath;
processAudit["managed_entry_sha256"] = managedEntry.Sha256;
}
- audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit));
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit, SessionId: sessionId));
WAIT_EVENT wait = PInvoke.WaitForSingleObject(process.hProcess, checked((uint)Math.Clamp(timeout.TotalMilliseconds, 1, uint.MaxValue - 1)));
if (wait == WAIT_EVENT.WAIT_TIMEOUT)
{
_ = PInvoke.TerminateJobObject(job, 1460);
- audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.timeout", "terminated", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString() }));
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.timeout", "terminated", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString() }, SessionId: sessionId));
throw new TimeoutException("Sandbox process exceeded its timeout.");
}
if (!PInvoke.GetExitCodeProcess(processView, out uint exitCode)) throw LastError("GetExitCodeProcess");
- audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.exit", "observed", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() }));
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.exit", "observed", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() }, SessionId: sessionId));
if (exitCode != 0)
{
audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "observed", new Dictionary<string, string>
{
["code"] = "restricted_process_failure", ["pid"] = process.dwProcessId.ToString(),
["child_process_policy"] = "restricted", ["exit_code"] = exitCode.ToString()
- }));
+ }, SessionId: sessionId, ErrorCode: "process.child_denied"));
}
return new(process.dwProcessId, exitCode, ReadCapture(stdout), ReadCapture(stderr));
}
diff --git a/src/Agent.Windows/Security/SecurityAudit.cs b/src/Agent.Windows/Security/SecurityAudit.cs
index 8858c2b..616b08f 100644
--- a/src/Agent.Windows/Security/SecurityAudit.cs
+++ b/src/Agent.Windows/Security/SecurityAudit.cs
@@ -2,7 +2,8 @@ using System.Text.Json;
namespace Agent.Windows.Security;
-public sealed record SecurityAuditEvent(DateTimeOffset Timestamp, Guid TaskId, string Event, string Outcome, IReadOnlyDictionary<string, string> Data);
+public sealed record SecurityAuditEvent(DateTimeOffset Timestamp, Guid TaskId, string Event, string Outcome,
+ IReadOnlyDictionary<string, string> Data, Guid? SessionId = null, string? ErrorCode = null);
public interface ISecurityAuditSink
{
@@ -12,11 +13,12 @@ public interface ISecurityAuditSink
public sealed class JsonLinesSecurityAuditSink(string path) : ISecurityAuditSink
{
private readonly object _sync = new();
+ private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public void Write(SecurityAuditEvent auditEvent)
{
string? directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (directory is not null) Directory.CreateDirectory(directory);
- string line = JsonSerializer.Serialize(auditEvent);
+ string line = JsonSerializer.Serialize(auditEvent, Json);
lock (_sync) File.AppendAllText(path, line + Environment.NewLine);
}
}
diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs
index 6e62d3d..5c1fe49 100644
--- a/src/Agent.Windows/Tools/WindowsToolBackend.cs
+++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs
@@ -22,7 +22,8 @@ public sealed record WindowsSandboxSettings(
long MemoryBackupLimitBytes,
long WholeReadLimitBytes,
int SearchResultLimit,
- int TailLineLimit);
+ int TailLineLimit,
+ int ProcessTimeoutSeconds = 300);
public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurityAuditSink audit, ITaskUpdateHandler? taskUpdates = null) : IInternalToolBackend
{
@@ -37,7 +38,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
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);
+ var context = new TaskContext(journal, acl, sessionId);
_taskUpdates.BeginTask(taskId, sessionId);
try
{
@@ -57,8 +58,9 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
IReadOnlyList<NetworkEndpoint> allowances = settings.NetworkMode == "allow"
? await NetworkAllowanceSnapshot.ResolveAsync(settings.NetworkAllowances, cancellationToken).ConfigureAwait(false)
: [];
- context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit);
+ context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit, sessionId);
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));
}
catch
{
@@ -71,6 +73,8 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken)
{
TaskContext context = Get(taskId);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "tool.call", "attempted",
+ new Dictionary<string, string> { ["tool"] = toolName }, SessionId: context.SessionId));
try
{
return toolName switch
@@ -78,7 +82,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
"file_search" => Task.FromResult(Search(arguments)),
"file_read" => Task.FromResult(Read(arguments)),
"file_edit" => Task.FromResult(Edit(context, arguments)),
- "process_run" => Task.FromResult(RunProcess(taskId, arguments)),
+ "process_run" => Task.FromResult(RunProcess(context, arguments)),
"task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)),
"dir" => Task.FromResult(DirectoryOp(context, arguments)),
_ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.")
@@ -86,14 +90,28 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
}
catch (SandboxPolicyException ex)
{
- return Task.FromResult(new ToolExecutionResult(false, ex.Message, false, $"{toolName}:SandboxPolicyException:{ex.Message}", RetryAllowed: false));
+ 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));
}
}
public async Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken)
{
if (!_contexts.TryRemove(taskId, out TaskContext? context)) return;
- try { await CleanupAsync(taskId, context).ConfigureAwait(false); }
+ try
+ {
+ await CleanupAsync(taskId, context).ConfigureAwait(false);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "task.end", success ? "completed" : "stopped",
+ new Dictionary<string, string>(), SessionId: context.SessionId));
+ }
+ catch
+ {
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "task.end", "cleanup_failed",
+ new Dictionary<string, string>(), SessionId: context.SessionId));
+ throw;
+ }
finally { _taskUpdates.EndTask(taskId); }
}
@@ -188,7 +206,9 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
string auditPath = operation == "rename"
? $"{Path.GetFullPath(path)} -> {Path.GetFullPath(arguments.GetProperty("new_path").GetString()!)}"
: Path.GetFullPath(path);
- audit.Write(new(DateTimeOffset.UtcNow, context.Journal.TaskId, "file." + operation, "completed", new Dictionary<string, string> { ["path"] = auditPath }));
+ string eventName = operation == "delete" ? "file.delete" : "file.write";
+ audit.Write(new(DateTimeOffset.UtcNow, context.Journal.TaskId, eventName, "completed",
+ new Dictionary<string, string> { ["path"] = auditPath, ["operation"] = operation }, SessionId: context.SessionId));
return Success($"{operation} completed for {auditPath}", externalEffect: true);
}
@@ -225,8 +245,9 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
throw new ToolValidationException($"Unsupported dir operation '{operation}'.");
}
- private ToolExecutionResult RunProcess(Guid taskId, JsonElement arguments)
+ private ToolExecutionResult RunProcess(TaskContext context, JsonElement arguments)
{
+ Guid taskId = context.Journal.TaskId;
string executable = arguments.GetProperty("executable").GetString()!;
string cwd = arguments.GetProperty("cwd").GetString()!;
_paths.Demand(executable, FileCapability.Execute);
@@ -234,10 +255,12 @@ 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() : 300;
+ 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}).");
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);
+ .Run(taskId, executable, args, cwd, TimeSpan.FromSeconds(seconds), stdin, context.SessionId);
string output = $"Process exited with code {result.ExitCode} (PID {result.ProcessId}).\nstdout:\n{result.StandardOutput}\nstderr:\n{result.StandardError}";
return new(result.ExitCode == 0, output, true,
result.ExitCode == 0 ? null : $"process:{executable}:{result.ExitCode}");
@@ -276,9 +299,10 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
return current[..first] + newText + current[(first + oldText.Length)..];
}
- private sealed class TaskContext(TaskResourceJournal journal, TaskAclManager acl)
+ private sealed class TaskContext(TaskResourceJournal journal, TaskAclManager acl, Guid sessionId)
{
public TaskResourceJournal Journal { get; } = journal;
+ public Guid SessionId { get; } = sessionId;
public TaskAclManager Acl { get; } = acl;
public List<AclSnapshot> Acls { get; } = [];
public TaskNetworkPolicy? Network { get; set; }