From b07b41cd435991cd254957af79055080c85b346e Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 9 Aug 2026 21:08:20 +0900 Subject: feat: AI Agent Sandbox MCP Server v1を実装 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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まで生成可能にした。 --- src/Agent.Windows/Recovery/TaskResourceJournal.cs | 108 ++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 src/Agent.Windows/Recovery/TaskResourceJournal.cs (limited to 'src/Agent.Windows/Recovery/TaskResourceJournal.cs') diff --git a/src/Agent.Windows/Recovery/TaskResourceJournal.cs b/src/Agent.Windows/Recovery/TaskResourceJournal.cs new file mode 100644 index 0000000..b638041 --- /dev/null +++ b/src/Agent.Windows/Recovery/TaskResourceJournal.cs @@ -0,0 +1,108 @@ +using System.Text.Json; + +namespace Agent.Windows.Recovery; + +public enum JournalEntryState { Prepared, Applied, Cleaned } + +public sealed class JournalEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Kind { get; set; } = string.Empty; + public string Target { get; set; } = string.Empty; + public JournalEntryState State { get; set; } + public Dictionary Data { get; set; } = new(StringComparer.Ordinal); +} + +public sealed class JournalDocument +{ + public int SchemaVersion { get; set; } = 1; + public Guid TaskId { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public List Entries { get; set; } = []; +} + +public sealed class TaskResourceJournal +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + private readonly object _sync = new(); + private readonly JournalDocument _document; + + private TaskResourceJournal(string path, JournalDocument document) { Path = path; _document = document; } + public string Path { get; } + public Guid TaskId => _document.TaskId; + public IReadOnlyList Entries { get { lock (_sync) return _document.Entries.Select(Clone).ToArray(); } } + + public static TaskResourceJournal Create(string journalDirectory, Guid taskId) + { + Directory.CreateDirectory(journalDirectory); + string path = System.IO.Path.Combine(journalDirectory, taskId.ToString("N") + ".journal.json"); + if (File.Exists(path)) throw new InvalidOperationException($"Journal already exists for task '{taskId}'."); + var journal = new TaskResourceJournal(path, new JournalDocument { TaskId = taskId }); + journal.Persist(); + return journal; + } + + public static TaskResourceJournal Open(string path) + { + JournalDocument document = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) + ?? throw new InvalidDataException($"Journal '{path}' is empty."); + if (document.SchemaVersion != 1 || document.TaskId == Guid.Empty) throw new InvalidDataException($"Journal '{path}' is invalid."); + return new(path, document); + } + + public Guid Prepare(string kind, string target, IReadOnlyDictionary data) + { + lock (_sync) + { + var entry = new JournalEntry { Kind = kind, Target = target, State = JournalEntryState.Prepared, Data = new(data, StringComparer.Ordinal) }; + _document.Entries.Add(entry); + Persist(); + return entry.Id; + } + } + + public void MarkApplied(Guid entryId, IReadOnlyDictionary? additionalData = null) + { + lock (_sync) + { + JournalEntry entry = Get(entryId); + if (entry.State != JournalEntryState.Prepared) throw new InvalidOperationException("Only prepared resources can be marked applied."); + if (additionalData is not null) foreach ((string key, string value) in additionalData) entry.Data[key] = value; + entry.State = JournalEntryState.Applied; + Persist(); + } + } + + public void MarkCleaned(Guid entryId) + { + lock (_sync) + { + JournalEntry entry = Get(entryId); + if (entry.State == JournalEntryState.Cleaned) return; + entry.State = JournalEntryState.Cleaned; + Persist(); + } + } + + public void CompleteAndDelete() + { + lock (_sync) + { + if (_document.Entries.Any(entry => entry.State != JournalEntryState.Cleaned)) throw new InvalidOperationException("Journal still contains live resources."); + File.Delete(Path); + } + } + + private JournalEntry Get(Guid id) => _document.Entries.SingleOrDefault(entry => entry.Id == id) ?? throw new KeyNotFoundException($"Journal entry '{id}' not found."); + private void Persist() + { + string temporary = Path + ".tmp"; + using (FileStream stream = new(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + JsonSerializer.Serialize(stream, _document, JsonOptions); + stream.Flush(flushToDisk: true); + } + File.Move(temporary, Path, overwrite: true); + } + private static JournalEntry Clone(JournalEntry source) => new() { Id = source.Id, Kind = source.Kind, Target = source.Target, State = source.State, Data = new(source.Data, StringComparer.Ordinal) }; +} -- cgit v1.2.3