summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Recovery/RecoveryManager.cs
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
commitb07b41cd435991cd254957af79055080c85b346e (patch)
tree27720d3ce14e4d0b84670a55ac26a2f877ce3a20 /src/Agent.Windows/Recovery/RecoveryManager.cs
parent2fefd29e86c3b926d0cf4a362251d3f119688612 (diff)
feat: AI Agent Sandbox MCP Server v1を実装
Specifications、Design、ImplementationPlanの優先順位を整理し、単一Task実行モデル、typed option優先、設定可能なresource上限、local Ollamaを必須とするRelease Gateを文書化。 .NET 10/C# 14の5 production project、3 test project、E2E Clientを構築。typed YAML、en/ja locale、fixed/user prompt、stdioおよびloopback限定Streamable HTTP、agent_run/agent_status、Session、Ollama text/tool/Vision、TaskGraphとtoken accountingを実装。 Windows sandboxとしてfixed AppContainer、canonical/reparse検査、能力別ACL、path+SHA-256 executable approval、child-process restriction、Job Object、task journal、atomic file transaction、crash recoveryを実装。WFPのIPv4/IPv6 default denyとexact allow、dynamic session、temporary loopback exemption、管理者/BFE診断を追加。 Security/Recovery test、MCP/Ollama integration test、stdio/HTTP Operational E2E、Release Bundle生成・checksum・manifest・fresh extraction検査を追加。Requirement TraceabilityとPhase 0〜11の進捗、初回セットアップ可能なREADMEを整備。 Debug/Release/publish/objをrepository rootのbuild/へ集約し、旧project-local bin/objへの依存を除去。artifacts/とbuild/をGit管理外に設定し、clean checkoutでもsolution buildからE2E Clientまで生成可能にした。
Diffstat (limited to 'src/Agent.Windows/Recovery/RecoveryManager.cs')
-rw-r--r--src/Agent.Windows/Recovery/RecoveryManager.cs114
1 files changed, 114 insertions, 0 deletions
diff --git a/src/Agent.Windows/Recovery/RecoveryManager.cs b/src/Agent.Windows/Recovery/RecoveryManager.cs
new file mode 100644
index 0000000..df69a5e
--- /dev/null
+++ b/src/Agent.Windows/Recovery/RecoveryManager.cs
@@ -0,0 +1,114 @@
+using System.Security.AccessControl;
+using System.Security.Cryptography;
+using Agent.Windows.Security;
+using Agent.Windows.Network;
+
+namespace Agent.Windows.Recovery;
+
+public sealed record RecoveryResult(int JournalsRecovered, int ResourcesRecovered, IReadOnlyList<string> Errors)
+{
+ public bool IsClean => Errors.Count == 0;
+}
+
+public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink audit)
+{
+ public RecoveryResult RecoverAll()
+ {
+ 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))
+ {
+ journals++;
+ try
+ {
+ TaskResourceJournal journal = TaskResourceJournal.Open(path);
+ foreach (JournalEntry entry in journal.Entries.Reverse())
+ {
+ if (entry.State == JournalEntryState.Cleaned) continue;
+ if (entry.State == JournalEntryState.Applied) RecoverEntry(journal.TaskId, entry);
+ CleanupPrepared(entry);
+ journal.MarkCleaned(entry.Id);
+ resources++;
+ }
+ journal.CompleteAndDelete();
+ audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.complete", "clean", new Dictionary<string, string>()));
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException)
+ {
+ errors.Add($"{path}: {ex.Message}");
+ }
+ }
+ return new(journals, resources, errors);
+ }
+
+ private static void RecoverEntry(Guid taskId, JournalEntry entry)
+ {
+ switch (entry.Kind)
+ {
+ case "file.replace": RecoverFile(entry); break;
+ case "file.delete": RecoverDeletedFile(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;
+ default: throw new InvalidDataException($"Unknown journal resource kind '{entry.Kind}' for task '{taskId}'.");
+ }
+ }
+
+ private static void RecoverLoopback(JournalEntry entry)
+ {
+ if (!bool.Parse(entry.Data["originally_present"])) LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target));
+ }
+
+ private static void RecoverFile(JournalEntry entry)
+ {
+ string replacement = entry.Data["replacement_sha256"];
+ if (!File.Exists(entry.Target) || !Hash(entry.Target).Equals(replacement, StringComparison.Ordinal))
+ throw new InvalidOperationException($"File '{entry.Target}' changed after crash; automatic rollback refused.");
+ if (bool.Parse(entry.Data["existed"]))
+ {
+ byte[] original = entry.Data["backup_path"].Length > 0 ? File.ReadAllBytes(entry.Data["backup_path"]) : Convert.FromBase64String(entry.Data["original_base64"]);
+ if (!Convert.ToHexString(SHA256.HashData(original)).Equals(entry.Data["original_sha256"], StringComparison.Ordinal)) throw new InvalidDataException("Recovery backup hash mismatch.");
+ string temporary = entry.Target + ".recovery.tmp";
+ File.WriteAllBytes(temporary, original);
+ File.Move(temporary, entry.Target, overwrite: true);
+ }
+ else File.Delete(entry.Target);
+ }
+
+ private static void RecoverAcl(JournalEntry entry)
+ {
+ byte[] descriptor = Convert.FromBase64String(entry.Data["security_descriptor"]);
+ FileSystemSecurity security = Directory.Exists(entry.Target) ? new DirectorySecurity() : new FileSecurity();
+ security.SetSecurityDescriptorBinaryForm(descriptor, AccessControlSections.Access);
+ if (security is DirectorySecurity directory) FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(entry.Target), directory);
+ else FileSystemAclExtensions.SetAccessControl(new FileInfo(entry.Target), (FileSecurity)security);
+ }
+
+ private static void RecoverDeletedFile(JournalEntry entry)
+ {
+ if (File.Exists(entry.Target)) throw new InvalidOperationException($"Deleted path '{entry.Target}' was recreated after crash; automatic recovery refused.");
+ byte[] original = entry.Data["backup_path"].Length > 0 ? File.ReadAllBytes(entry.Data["backup_path"]) : Convert.FromBase64String(entry.Data["original_base64"]);
+ if (!Convert.ToHexString(SHA256.HashData(original)).Equals(entry.Data["original_sha256"], StringComparison.Ordinal)) throw new InvalidDataException("Delete recovery backup hash mismatch.");
+ File.WriteAllBytes(entry.Target, original);
+ }
+
+ private static void CleanupPrepared(JournalEntry entry)
+ {
+ if (entry.Kind == "network.loopback_exemption" && !bool.Parse(entry.Data["originally_present"]))
+ LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target));
+ 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)
+ {
+ File.Delete(backup);
+ string? parent = System.IO.Path.GetDirectoryName(backup);
+ if (parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent);
+ }
+ }
+ private static string Hash(string target)
+ {
+ using FileStream stream = File.OpenRead(target);
+ return Convert.ToHexString(SHA256.HashData(stream));
+ }
+}