diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-11 21:44:53 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-11 21:44:53 +0900 |
| commit | 6c3458a5ec37a5b69957ad17c84de58984698f25 (patch) | |
| tree | be5c5716e7488fcf1bc1a58de0ded0b9238f34cb /src/Agent.Windows | |
| parent | b5ccecca01007f2a2459265df6d749fbfd31aa84 (diff) | |
Audit and align v1 runtime design
Diffstat (limited to 'src/Agent.Windows')
| -rw-r--r-- | src/Agent.Windows/Network/TaskNetworkPolicy.cs | 18 | ||||
| -rw-r--r-- | src/Agent.Windows/Recovery/RecoveryManager.cs | 3 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/ExecutablePolicy.cs | 4 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/PathPolicy.cs | 9 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/RestrictedProcessLauncher.cs | 15 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/SecurityAudit.cs | 6 | ||||
| -rw-r--r-- | src/Agent.Windows/Tools/WindowsToolBackend.cs | 46 |
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; } |
