summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Recovery/TransactionalFileEditor.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/TransactionalFileEditor.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/TransactionalFileEditor.cs')
-rw-r--r--src/Agent.Windows/Recovery/TransactionalFileEditor.cs141
1 files changed, 141 insertions, 0 deletions
diff --git a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
new file mode 100644
index 0000000..52963a0
--- /dev/null
+++ b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
@@ -0,0 +1,141 @@
+using System.Security.Cryptography;
+using System.Text;
+using Agent.Windows.Security;
+
+namespace Agent.Windows.Recovery;
+
+public sealed class TransactionalFileEditor(TaskResourceJournal journal, string backupRoot, long memoryBackupLimitBytes = 16 * 1024 * 1024)
+{
+ public FileEditTransaction Replace(string path, string content, string? expectedSha256 = null) => Replace(path, Encoding.UTF8.GetBytes(content), expectedSha256);
+
+ public FileEditTransaction Replace(string path, ReadOnlySpan<byte> content, string? expectedSha256 = null)
+ {
+ string canonical = CanonicalPath.Resolve(path, requireExisting: false);
+ bool existed = File.Exists(canonical);
+ byte[]? original = existed ? File.ReadAllBytes(canonical) : null;
+ string originalHash = original is null ? string.Empty : Convert.ToHexString(SHA256.HashData(original));
+ if (expectedSha256 is not null && !originalHash.Equals(NormalizeHash(expectedSha256), StringComparison.Ordinal))
+ throw new SandboxPolicyException("file.concurrent_change", "File hash does not match expected_sha256.");
+
+ string backupPath = string.Empty;
+ string inline = string.Empty;
+ if (original is not null && original.LongLength > memoryBackupLimitBytes)
+ {
+ string directory = System.IO.Path.Combine(backupRoot, journal.TaskId.ToString("N"));
+ Directory.CreateDirectory(directory);
+ backupPath = System.IO.Path.Combine(directory, Guid.NewGuid().ToString("N") + ".backup");
+ File.WriteAllBytes(backupPath, original);
+ }
+ else if (original is not null) inline = Convert.ToBase64String(original);
+
+ string temporary = canonical + ".agent-mcp-" + Guid.NewGuid().ToString("N") + ".tmp";
+ var data = new Dictionary<string, string>
+ {
+ ["existed"] = existed.ToString(), ["original_sha256"] = originalHash, ["original_base64"] = inline,
+ ["backup_path"] = backupPath, ["temporary_path"] = temporary
+ };
+ Guid entryId = journal.Prepare("file.replace", canonical, data);
+ File.WriteAllBytes(temporary, content);
+ string replacementHash = Convert.ToHexString(SHA256.HashData(content));
+ File.Move(temporary, canonical, overwrite: true);
+ journal.MarkApplied(entryId, new Dictionary<string, string> { ["replacement_sha256"] = replacementHash });
+ return new(journal, entryId, canonical, originalHash, replacementHash, backupPath, inline, existed);
+ }
+
+ public FileDeleteTransaction Delete(string path, string? expectedSha256 = null)
+ {
+ string canonical = CanonicalPath.Resolve(path);
+ byte[] original = File.ReadAllBytes(canonical);
+ string originalHash = Convert.ToHexString(SHA256.HashData(original));
+ if (expectedSha256 is not null && !originalHash.Equals(NormalizeHash(expectedSha256), StringComparison.Ordinal))
+ throw new SandboxPolicyException("file.concurrent_change", "File hash does not match expected_sha256.");
+ string backupPath = string.Empty;
+ string inline = string.Empty;
+ if (original.LongLength > memoryBackupLimitBytes)
+ {
+ string directory = System.IO.Path.Combine(backupRoot, journal.TaskId.ToString("N"));
+ Directory.CreateDirectory(directory);
+ backupPath = System.IO.Path.Combine(directory, Guid.NewGuid().ToString("N") + ".backup");
+ File.WriteAllBytes(backupPath, original);
+ }
+ else inline = Convert.ToBase64String(original);
+ Guid entryId = journal.Prepare("file.delete", canonical, new Dictionary<string, string>
+ {
+ ["original_sha256"] = originalHash, ["original_base64"] = inline, ["backup_path"] = backupPath
+ });
+ File.Delete(canonical);
+ journal.MarkApplied(entryId);
+ return new(journal, entryId, canonical, originalHash, backupPath, inline);
+ }
+
+ private static string NormalizeHash(string hash) => hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant();
+}
+
+public sealed class FileDeleteTransaction(TaskResourceJournal journal, Guid entryId, string path, string originalHash, string backupPath, string originalBase64)
+{
+ private bool _finished;
+ public void Commit() { if (_finished) return; Cleanup(); journal.MarkCleaned(entryId); _finished = true; }
+ public void Rollback()
+ {
+ if (_finished) return;
+ if (File.Exists(path)) throw new InvalidOperationException("Rollback refused because the deleted path was recreated.");
+ byte[] original = backupPath.Length > 0 ? File.ReadAllBytes(backupPath) : Convert.FromBase64String(originalBase64);
+ if (!Convert.ToHexString(SHA256.HashData(original)).Equals(originalHash, StringComparison.Ordinal)) throw new InvalidDataException("Delete backup hash verification failed.");
+ File.WriteAllBytes(path, original);
+ Cleanup(); journal.MarkCleaned(entryId); _finished = true;
+ }
+ private void Cleanup()
+ {
+ if (backupPath.Length == 0) return;
+ File.Delete(backupPath);
+ string? parent = System.IO.Path.GetDirectoryName(backupPath);
+ if (parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent);
+ }
+}
+
+public sealed class FileEditTransaction(
+ TaskResourceJournal journal, Guid entryId, string path, string originalHash, string replacementHash,
+ string backupPath, string originalBase64, bool originallyExisted)
+{
+ private bool _finished;
+
+ public void Commit()
+ {
+ if (_finished) return;
+ DeleteBackup();
+ journal.MarkCleaned(entryId);
+ _finished = true;
+ }
+
+ public void Rollback()
+ {
+ if (_finished) return;
+ if (!File.Exists(path) || !Hash(path).Equals(replacementHash, StringComparison.Ordinal))
+ throw new InvalidOperationException("Rollback refused because the replacement file changed after the transaction.");
+ if (originallyExisted)
+ {
+ byte[] original = backupPath.Length > 0 ? File.ReadAllBytes(backupPath) : Convert.FromBase64String(originalBase64);
+ if (!Convert.ToHexString(SHA256.HashData(original)).Equals(originalHash, StringComparison.Ordinal)) throw new InvalidDataException("Backup hash verification failed.");
+ string temporary = path + ".rollback-" + Guid.NewGuid().ToString("N") + ".tmp";
+ File.WriteAllBytes(temporary, original);
+ File.Move(temporary, path, overwrite: true);
+ }
+ else File.Delete(path);
+ DeleteBackup();
+ journal.MarkCleaned(entryId);
+ _finished = true;
+ }
+
+ private void DeleteBackup()
+ {
+ if (backupPath.Length == 0) return;
+ File.Delete(backupPath);
+ string? parent = System.IO.Path.GetDirectoryName(backupPath);
+ 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));
+ }
+}