summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Recovery
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-13 22:48:24 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-13 22:48:24 +0900
commit9337ac3e5edd42bb17f5e2ade6c2de02c7ef576d (patch)
tree9a48fefc88cc046f4ce1e2bc34fa0a47351e4f83 /src/Agent.Windows/Recovery
parent36ae1a4cba791ec633a81b38b1d8345af8487276 (diff)
feat!: isolate privileged sandbox network operations per task
Run agent-mcp with an explicit asInvoker manifest and move WFP and loopback mutations into a fixed requireAdministrator helper under the release admin directory. Add the Agent.Admin client and host projects with task-scoped runas launch, a logon-SID and Administrators-only local named pipe, remote-client rejection, mutual PID validation, strict versioned JSON framing, fixed network-only operations, and bounded messages. Delay ACL and network Task Resource application until the first process_run. Verify executable identity, managed entry points, DNS snapshots, and normal-token WRITE_DAC access before elevation; reuse the helper only within the Task and keep elevation failures terminal without policy replanning. Keep LPAC creation, executable approval, Job confinement, and ACL leases in the normal process. Make startup platform checks read-only, create the fixed Application Profile during apply, preserve dynamic WFP lifetime in the helper, and route persistent loopback recovery through the same elevated boundary. Update recovery journaling, localized elevation and IPC errors, audit events, release publishing, manifests, README guidance, Specifications, Design, implementation records, and requirement traceability for the on-demand elevation model. Add administrative-boundary, file-only no-elevation, elevation-terminal, lazy-resource, crash-recovery, manifest, IPC schema, and operational verification coverage. Verified Release build with zero warnings, Core 105/105, Planner 10/10, administrative boundary 4/4, elevated Windows suite 79/79, and published stdio/HTTP MCP E2E.
Diffstat (limited to 'src/Agent.Windows/Recovery')
-rw-r--r--src/Agent.Windows/Recovery/RecoveryManager.cs52
1 files changed, 40 insertions, 12 deletions
diff --git a/src/Agent.Windows/Recovery/RecoveryManager.cs b/src/Agent.Windows/Recovery/RecoveryManager.cs
index 9dfeef9..40a7923 100644
--- a/src/Agent.Windows/Recovery/RecoveryManager.cs
+++ b/src/Agent.Windows/Recovery/RecoveryManager.cs
@@ -2,6 +2,7 @@ using System.Security.AccessControl;
using System.Security.Cryptography;
using Agent.Windows.Security;
using Agent.Windows.Network;
+using Agent.Admin.Client;
namespace Agent.Windows.Recovery;
@@ -10,15 +11,39 @@ public sealed record RecoveryResult(int JournalsRecovered, int ResourcesRecovere
public bool IsClean => Errors.Count == 0;
}
-public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink audit)
+public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink audit, IElevatedNetworkSessionFactory? elevatedNetwork = null)
{
- public RecoveryResult RecoverAll()
+ public RecoveryResult RecoverAll() => RecoverAllAsync(CancellationToken.None).GetAwaiter().GetResult();
+
+ public async Task<RecoveryResult> RecoverAllAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(journalDirectory)) return new(0, 0, []);
int journals = 0;
int resources = 0;
var errors = new List<string>();
- foreach (string path in Directory.EnumerateFiles(journalDirectory, "*.journal.json").Order(StringComparer.Ordinal))
+ string[] paths = Directory.EnumerateFiles(journalDirectory, "*.journal.json").Order(StringComparer.Ordinal).ToArray();
+ bool requiresElevation = paths.Select(TaskResourceJournal.Open).SelectMany(static item => item.Entries)
+ .Any(static entry => entry.State != JournalEntryState.Cleaned && entry.Kind == "network.loopback_exemption");
+ IElevatedNetworkSession? elevated = null;
+ if (requiresElevation)
+ {
+ if (elevatedNetwork is null) return new(0, 0, ["Persistent network Recovery requires the elevated helper."]);
+ audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "sandbox.elevation.request", "requested",
+ new Dictionary<string, string> { ["reason"] = "startup_recovery" }));
+ try { elevated = await elevatedNetwork.StartAsync(Guid.Empty, cancellationToken).ConfigureAwait(false); }
+ catch (AdminOperationException ex)
+ {
+ audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty,
+ ex.Code == "sandbox.ipc_rejected" ? "sandbox.ipc.reject" : "sandbox.elevation.deny", "failed",
+ new Dictionary<string, string> { ["reason"] = "startup_recovery", ["message"] = ex.Message }, ErrorCode: ex.Code));
+ throw;
+ }
+ audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "sandbox.elevation.success", "connected",
+ new Dictionary<string, string> { ["reason"] = "startup_recovery" }));
+ }
+ try
+ {
+ foreach (string path in paths)
{
journals++;
try
@@ -28,8 +53,8 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink
foreach (JournalEntry entry in journal.Entries.Reverse())
{
if (entry.State == JournalEntryState.Cleaned) continue;
- if (entry.State == JournalEntryState.Applied) RecoverEntry(journal.TaskId, entry);
- CleanupPrepared(entry);
+ if (entry.State == JournalEntryState.Applied) await RecoverEntryAsync(journal.TaskId, entry, elevated, cancellationToken).ConfigureAwait(false);
+ await CleanupPreparedAsync(entry, elevated, cancellationToken).ConfigureAwait(false);
journal.MarkCleaned(entry.Id);
resources++;
}
@@ -41,10 +66,12 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink
errors.Add($"{path}: {ex.Message}");
}
}
+ }
+ finally { if (elevated is not null) await elevated.DisposeAsync().ConfigureAwait(false); }
return new(journals, resources, errors);
}
- private static void RecoverEntry(Guid taskId, JournalEntry entry)
+ private static async Task RecoverEntryAsync(Guid taskId, JournalEntry entry, IElevatedNetworkSession? elevated, CancellationToken cancellationToken)
{
switch (entry.Kind)
{
@@ -55,14 +82,15 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink
case "dir.rmdir": RecoverRmdir(entry); break;
case "acl": RecoverAcl(entry); break;
case "wfp.dynamic": break; // BFE removes every object when the originating dynamic session ends.
- case "network.loopback_exemption": RecoverLoopback(entry); break;
+ case "network.loopback_exemption": await RecoverLoopbackAsync(entry, elevated, cancellationToken).ConfigureAwait(false); break;
default: throw new InvalidDataException($"Unknown journal resource kind '{entry.Kind}' for task '{taskId}'.");
}
}
- private static void RecoverLoopback(JournalEntry entry)
+ private static async Task RecoverLoopbackAsync(JournalEntry entry, IElevatedNetworkSession? elevated, CancellationToken cancellationToken)
{
- if (!bool.Parse(entry.Data["originally_present"])) LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target));
+ if (elevated is null) throw new InvalidOperationException("Persistent network Recovery requires the elevated helper.");
+ await elevated.RecoverLoopbackAsync(entry.Target, bool.Parse(entry.Data["originally_present"]), cancellationToken).ConfigureAwait(false);
}
private static void RecoverFile(JournalEntry entry)
@@ -124,10 +152,10 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink
Directory.CreateDirectory(entry.Target);
}
- private static void CleanupPrepared(JournalEntry entry)
+ private static async Task CleanupPreparedAsync(JournalEntry entry, IElevatedNetworkSession? elevated, CancellationToken cancellationToken)
{
- if (entry.Kind == "network.loopback_exemption" && !bool.Parse(entry.Data["originally_present"]))
- LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target));
+ if (entry.Kind == "network.loopback_exemption" && entry.State != JournalEntryState.Applied)
+ await RecoverLoopbackAsync(entry, elevated, cancellationToken).ConfigureAwait(false);
if (entry.Data.TryGetValue("temporary_path", out string? temporary) && temporary.Length > 0) File.Delete(temporary);
if (entry.Data.TryGetValue("backup_path", out string? backup) && backup.Length > 0)
{