From e8b625e22f343d75e1b50d1634b4ab06899d060b Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Mon, 10 Aug 2026 19:38:55 +0900 Subject: feat(tools): add dir internal tool for directory operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新規Internal Tool `dir` を追加し、ディレクトリの作成・削除・一覧取得を 可能にした。file_editの拡張ではなく独立toolとして実装し、LLMが 「ディレクトリ操作はdir tool」と直感的に判断できるようにした。 追加した機能: - dir tool (6番目のInternal Tool、MCP非公開) - mkdir: Write capability要求、Directory.CreateDirectory()で親ディレクトリも含めて作成 - rmdir: Delete capability要求、空ディレクトリのみ削除(非空はToolValidationExceptionで拒否) - list: Read capability要求、直下エントリ一覧を返す 設計判断: - file_edit拡張ではなく新規toolとした理由: ツール名から機能が推測しやすく、 LLMが直感的に選択できるため - rmdirを空ディレクトリのみに制限した理由: rollback時の内容復元の複雑さを排除、 MemoryBackupLimit超過リスクなし、安全側 - mkdir/rmdirは既存のPathPolicy/ACL/Journal/Recovery枠組みを利用 - root継承ACE方式のため新規作成ディレクトリは自動的に親のACEを継承、追加ACL操作不要 変更対象: - InternalToolCatalog.cs: dir定義追加 (5→6 tool) - InternalToolDispatcher.cs: FakeInternalToolBackendの効果判定にdir追加 - InternalToolValidator.cs: dir用コメント追記 - WindowsToolBackend.cs: DirectoryOp()メソッド実装 - TransactionalFileEditor.cs: Mkdir()/Rmdir() transaction + DirectoryMkdirTransaction/DirectoryRmdirTransaction クラス追加 - RecoveryManager.cs: dir.mkdir/dir.rmdir crash recovery追加 - Specifications.md: FS-06追加 - Design.md: 6群表更新、§7.6 dir追加、5群→6群参照更新 テスト: - InternalToolTests.cs: dir単体テスト、catalog 6群検証、malformed args拒否 - SecurityPolicyTests.cs: mkdir/rmdir/list/許可外deny テスト追加 - PromptAndRuntimeTests.cs: tool count 5→6 - OllamaClientTests.cs: tool catalog配列長 5→6 検証結果: - Release build: 警告0、エラー0 - Planner tests: 10/10 PASS、skip 0 - Core tests: 56/56 PASS、skip 0 - MCP経由での動作確認: mkdir/list/rmdir/file_edit すべて正常 - Python実行確認: dir mkdir + file_edit + process_run でfib(1)〜fib(10)が正常出力 - Release Bundle publish済み 既知の運用上の知見: - uv venvのpyvenv.cfgのhomeがjunctionを指す場合、CanonicalPath.Resolveが reparse pointとして拒否する。実体パスへ書き換えるか、実体ディレクトリを agent.yamlのread/executeに追加する必要がある - venvのpython.exeはuv trampolineで子プロセスを生成するため、 sandboxのchild-process制限で拒否される。pyvenv.cfgが指す実体python.exeを 直接起動する必要がある --- src/Agent.Windows/Recovery/RecoveryManager.cs | 16 +++++++ .../Recovery/TransactionalFileEditor.cs | 51 ++++++++++++++++++++++ 2 files changed, 67 insertions(+) (limited to 'src/Agent.Windows/Recovery') diff --git a/src/Agent.Windows/Recovery/RecoveryManager.cs b/src/Agent.Windows/Recovery/RecoveryManager.cs index df69a5e..8fd7368 100644 --- a/src/Agent.Windows/Recovery/RecoveryManager.cs +++ b/src/Agent.Windows/Recovery/RecoveryManager.cs @@ -49,6 +49,8 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink { case "file.replace": RecoverFile(entry); break; case "file.delete": RecoverDeletedFile(entry); break; + case "dir.mkdir": RecoverMkdir(entry); break; + 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; @@ -94,6 +96,20 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink File.WriteAllBytes(entry.Target, original); } + private static void RecoverMkdir(JournalEntry entry) + { + if (!Directory.Exists(entry.Target)) return; + if (Directory.EnumerateFileSystemEntries(entry.Target, "*", SearchOption.TopDirectoryOnly).Any()) + throw new InvalidOperationException($"Directory '{entry.Target}' created by mkdir is not empty; automatic rollback refused."); + Directory.Delete(entry.Target, recursive: false); + } + + private static void RecoverRmdir(JournalEntry entry) + { + if (Directory.Exists(entry.Target)) throw new InvalidOperationException($"Directory '{entry.Target}' was recreated after crash; automatic recovery refused."); + Directory.CreateDirectory(entry.Target); + } + private static void CleanupPrepared(JournalEntry entry) { if (entry.Kind == "network.loopback_exemption" && !bool.Parse(entry.Data["originally_present"])) diff --git a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs index 52963a0..805a069 100644 --- a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs +++ b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text; +using Agent.Core.Tools; using Agent.Windows.Security; namespace Agent.Windows.Recovery; @@ -68,6 +69,30 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string return new(journal, entryId, canonical, originalHash, backupPath, inline); } + public DirectoryMkdirTransaction Mkdir(string path) + { + string canonical = CanonicalPath.Resolve(path, requireExisting: false); + bool existed = Directory.Exists(canonical); + if (existed) throw new InvalidOperationException($"Directory already exists: '{canonical}'."); + if (File.Exists(canonical)) throw new InvalidOperationException($"A file exists at the target path: '{canonical}'."); + Guid entryId = journal.Prepare("dir.mkdir", canonical, new Dictionary { ["existed"] = existed.ToString() }); + Directory.CreateDirectory(canonical); + journal.MarkApplied(entryId); + return new(journal, entryId, canonical); + } + + public DirectoryRmdirTransaction Rmdir(string path) + { + string canonical = CanonicalPath.Resolve(path); + if (!Directory.Exists(canonical)) throw new DirectoryNotFoundException($"Directory does not exist: '{canonical}'."); + if (Directory.EnumerateFileSystemEntries(canonical, "*", SearchOption.TopDirectoryOnly).Any()) + throw new ToolValidationException("Directory is not empty. Remove contents first before removing the directory."); + Guid entryId = journal.Prepare("dir.rmdir", canonical, new Dictionary()); + Directory.Delete(canonical, recursive: false); + journal.MarkApplied(entryId); + return new(journal, entryId, canonical); + } + private static string NormalizeHash(string hash) => hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant(); } @@ -93,6 +118,32 @@ public sealed class FileDeleteTransaction(TaskResourceJournal journal, Guid entr } } +public sealed class DirectoryMkdirTransaction(TaskResourceJournal journal, Guid entryId, string path) +{ + private bool _finished; + public void Commit() { if (_finished) return; journal.MarkCleaned(entryId); _finished = true; } + public void Rollback() + { + if (_finished) return; + if (Directory.Exists(path)) Directory.Delete(path, recursive: false); + journal.MarkCleaned(entryId); + _finished = true; + } +} + +public sealed class DirectoryRmdirTransaction(TaskResourceJournal journal, Guid entryId, string path) +{ + private bool _finished; + public void Commit() { if (_finished) return; journal.MarkCleaned(entryId); _finished = true; } + public void Rollback() + { + if (_finished) return; + if (!Directory.Exists(path)) Directory.CreateDirectory(path); + journal.MarkCleaned(entryId); + _finished = true; + } +} + public sealed class FileEditTransaction( TaskResourceJournal journal, Guid entryId, string path, string originalHash, string replacementHash, string backupPath, string originalBase64, bool originallyExisted) -- cgit v1.2.3