diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-10 19:38:55 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-10 19:38:55 +0900 |
| commit | e8b625e22f343d75e1b50d1634b4ab06899d060b (patch) | |
| tree | 80b8d5d045f9402884a75cf2c74d1e15cb3ba9aa /src | |
| parent | f2001b0c5a0b35e052ba62ef4f2e19bcc64fd870 (diff) | |
feat(tools): add dir internal tool for directory operations
新規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を
直接起動する必要がある
Diffstat (limited to 'src')
| -rw-r--r-- | src/Agent.Core/Tools/InternalToolCatalog.cs | 3 | ||||
| -rw-r--r-- | src/Agent.Core/Tools/InternalToolDispatcher.cs | 2 | ||||
| -rw-r--r-- | src/Agent.Core/Tools/InternalToolValidator.cs | 2 | ||||
| -rw-r--r-- | src/Agent.Windows/Recovery/RecoveryManager.cs | 16 | ||||
| -rw-r--r-- | src/Agent.Windows/Recovery/TransactionalFileEditor.cs | 51 | ||||
| -rw-r--r-- | src/Agent.Windows/Tools/WindowsToolBackend.cs | 34 |
6 files changed, 106 insertions, 2 deletions
diff --git a/src/Agent.Core/Tools/InternalToolCatalog.cs b/src/Agent.Core/Tools/InternalToolCatalog.cs index 1563c16..3ab17b1 100644 --- a/src/Agent.Core/Tools/InternalToolCatalog.cs +++ b/src/Agent.Core/Tools/InternalToolCatalog.cs @@ -19,7 +19,8 @@ public static class InternalToolCatalog Define("file_read", "Read an approved file.", """{"type":"object","additionalProperties":false,"required":["path","mode"],"properties":{"path":{"type":"string"},"mode":{"type":"string","enum":["whole","range","metadata"]},"start":{"type":"integer","minimum":0},"length":{"type":"integer","minimum":0}}}"""), Define("file_edit", "Edit an approved file transactionally.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["overwrite","patch","atomic_replace","delete"]},"content":{"type":["string","null"]},"patch":{"type":["string","null"]},"expected_sha256":{"type":["string","null"]}}}"""), Define("process_run", "Run one approved executable with separated arguments.", """{"type":"object","additionalProperties":false,"required":["executable","args","cwd"],"properties":{"executable":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":"string"},"timeout_seconds":{"type":"integer","minimum":1},"stdin":{"type":["string","null"]}}}"""), - Define("task_update", "Update the deterministic task graph.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","set_state","set_estimate"]},"task_id":{"type":["string","null"]},"description":{"type":["string","null"]},"depends_on":{"type":"array","items":{"type":"string"}},"budget_class":{"type":["string","null"],"enum":["1K","2K","4K","8K","16K","32K","64K","128K",null]},"optimistic_tokens":{"type":"integer","minimum":0},"most_likely_tokens":{"type":"integer","minimum":0},"pessimistic_tokens":{"type":"integer","minimum":0},"state":{"type":["string","null"],"enum":["queued","planning","running","completed","failed","cancelled",null]}}}""") + Define("task_update", "Update the deterministic task graph.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","set_state","set_estimate"]},"task_id":{"type":["string","null"]},"description":{"type":["string","null"]},"depends_on":{"type":"array","items":{"type":"string"}},"budget_class":{"type":["string","null"],"enum":["1K","2K","4K","8K","16K","32K","64K","128K",null]},"optimistic_tokens":{"type":"integer","minimum":0},"most_likely_tokens":{"type":"integer","minimum":0},"pessimistic_tokens":{"type":"integer","minimum":0},"state":{"type":["string","null"],"enum":["queued","planning","running","completed","failed","cancelled",null]}}}"""), + Define("dir", "Create, remove, or list directories under an approved root. Only empty directories can be removed.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["mkdir","rmdir","list"]}}}""") ]; return definitions.ToDictionary(static definition => definition.Name, StringComparer.Ordinal); } diff --git a/src/Agent.Core/Tools/InternalToolDispatcher.cs b/src/Agent.Core/Tools/InternalToolDispatcher.cs index 0dca5df..c888705 100644 --- a/src/Agent.Core/Tools/InternalToolDispatcher.cs +++ b/src/Agent.Core/Tools/InternalToolDispatcher.cs @@ -23,7 +23,7 @@ public sealed class FakeInternalToolBackend : IInternalToolBackend public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) { _calls.Add((toolName, arguments.Clone())); - bool effect = toolName is "file_edit" or "process_run"; + bool effect = toolName is "file_edit" or "process_run" or "dir"; return Task.FromResult(new ToolExecutionResult(true, "fake-ok", effect)); } } diff --git a/src/Agent.Core/Tools/InternalToolValidator.cs b/src/Agent.Core/Tools/InternalToolValidator.cs index c48719e..091b790 100644 --- a/src/Agent.Core/Tools/InternalToolValidator.cs +++ b/src/Agent.Core/Tools/InternalToolValidator.cs @@ -75,6 +75,8 @@ public static class InternalToolValidator if (operation == "set_state") Require(arguments, "task_id", "state"); if (operation == "set_estimate") Require(arguments, "task_id", "budget_class", "optimistic_tokens", "most_likely_tokens", "pessimistic_tokens"); } + // dir: path and operation are already required by the schema; no operation-specific + // additional arguments are needed since mkdir/rmdir/list only use the path field. static void Require(JsonElement value, params string[] properties) { 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<string, string> { ["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<string, string>()); + 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) diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs index c0d1561..bb54c6e 100644 --- a/src/Agent.Windows/Tools/WindowsToolBackend.cs +++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs @@ -78,6 +78,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit "file_edit" => Task.FromResult(Edit(context, arguments)), "process_run" => Task.FromResult(RunProcess(taskId, arguments)), "task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)), + "dir" => Task.FromResult(DirectoryOp(context, arguments)), _ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.") }; } @@ -172,6 +173,39 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit return Success($"{operation} completed for {Path.GetFullPath(path)}", externalEffect: true); } + private ToolExecutionResult DirectoryOp(TaskContext context, JsonElement arguments) + { + string path = arguments.GetProperty("path").GetString()!; + string operation = arguments.GetProperty("operation").GetString()!; + var editor = new TransactionalFileEditor(context.Journal, settings.BackupDirectory, settings.MemoryBackupLimitBytes); + if (operation == "mkdir") + { + _paths.Demand(path, FileCapability.Write); + DirectoryMkdirTransaction transaction = editor.Mkdir(path); + transaction.Commit(); + audit.Write(new(DateTimeOffset.UtcNow, context.Journal.TaskId, "dir.mkdir", "completed", new Dictionary<string, string> { ["path"] = Path.GetFullPath(path) })); + return Success($"mkdir completed for {Path.GetFullPath(path)}", externalEffect: true); + } + if (operation == "rmdir") + { + _paths.Demand(path, FileCapability.Delete); + DirectoryRmdirTransaction transaction = editor.Rmdir(path); + transaction.Commit(); + audit.Write(new(DateTimeOffset.UtcNow, context.Journal.TaskId, "dir.rmdir", "completed", new Dictionary<string, string> { ["path"] = Path.GetFullPath(path) })); + return Success($"rmdir completed for {Path.GetFullPath(path)}", externalEffect: true); + } + if (operation == "list") + { + _paths.Demand(path, FileCapability.Read); + string canonical = CanonicalPath.Resolve(path); + var entries = Directory.EnumerateFileSystemEntries(canonical, "*", SearchOption.TopDirectoryOnly) + .Select(Path.GetFullPath) + .ToArray(); + return Success(JsonSerializer.Serialize(entries)); + } + throw new ToolValidationException($"Unsupported dir operation '{operation}'."); + } + private ToolExecutionResult RunProcess(Guid taskId, JsonElement arguments) { string executable = arguments.GetProperty("executable").GetString()!; |
