summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-10 19:38:55 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-10 19:38:55 +0900
commite8b625e22f343d75e1b50d1634b4ab06899d060b (patch)
tree80b8d5d045f9402884a75cf2c74d1e15cb3ba9aa
parentf2001b0c5a0b35e052ba62ef4f2e19bcc64fd870 (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を 直接起動する必要がある
-rw-r--r--doc/Design.md16
-rw-r--r--doc/Specifications.md1
-rw-r--r--src/Agent.Core/Tools/InternalToolCatalog.cs3
-rw-r--r--src/Agent.Core/Tools/InternalToolDispatcher.cs2
-rw-r--r--src/Agent.Core/Tools/InternalToolValidator.cs2
-rw-r--r--src/Agent.Windows/Recovery/RecoveryManager.cs16
-rw-r--r--src/Agent.Windows/Recovery/TransactionalFileEditor.cs51
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs34
-rw-r--r--tests/Agent.Core.Tests/InternalToolTests.cs40
-rw-r--r--tests/Agent.Core.Tests/OllamaClientTests.cs2
-rw-r--r--tests/Agent.Core.Tests/PromptAndRuntimeTests.cs2
-rw-r--r--tests/Agent.Windows.Tests/SecurityPolicyTests.cs135
12 files changed, 295 insertions, 9 deletions
diff --git a/doc/Design.md b/doc/Design.md
index a908441..101a896 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -249,7 +249,7 @@ overrideはTask objectにcopyし、global configurationを変更しない。Sess
- 既定上限は4画像、1画像10 MiB、request合計20 MiBとする。
- model capability確認でVision非対応と判定した場合はOllama呼出し前にlocalized error。能力確認不能時は呼出しを行い、Ollama errorを正規化して返す。
### 6.5 Tool calling
-Ollamaに公開するInternal Toolは次の5群に固定する。個々のOS command (`grep.exe`等) をtoolとして増殖させない。
+Ollamaに公開するInternal Toolは次の6群に固定する。個々のOS command (`grep.exe`等) をtoolとして増殖させない。
| Tool | 責務 | 主なoperation |
| --- | --- | --- |
| file_search | 検索 | glob / name / text / regex / tail相当 |
@@ -257,6 +257,7 @@ Ollamaに公開するInternal Toolは次の5群に固定する。個々のOS com
| file_edit | 編集 | overwrite / patch / atomic_replace / delete |
| process_run | 承認済Process実行 | exe + args + cwd + timeout |
| task_update | TaskGraph更新 | add / dependency / state / estimate |
+| dir | ディレクトリ操作 | mkdir / rmdir / list |
---
## 7. Internal Tool Schema設計
### 7.1 file_search
@@ -316,7 +317,16 @@ Ollamaに公開するInternal Toolは次の5群に固定する。個々のOS com
}
```
PlannerがDAG、不変条件、completed immutable、max_tasksを再検査してから適用する。
-### 7.6 ToolUseGuard
+### 7.6 dir
+```json
+{"path":"string","operation":"mkdir|rmdir|list"}
+```
+- mkdir は Write capabilityを要求し、`Directory.CreateDirectory` で親ディレクトリも含めて作成する。
+- rmdir は Delete capabilityを要求し、**空ディレクトリのみ**削除する。非空の場合は `ToolValidationException` で拒否する。内容復元の複雑さとMemoryBackupLimit超過リスクを排除するため、再帰削除は行わない。
+- list は Read capabilityを要求し、直下のエントリ一覧を返す。外部効果ではない。
+- 既存の PathPolicy / CanonicalPath / TaskAclManager (root継承ACE) / TaskResourceJournal / RecoveryManager 枠組みを利用する。新規作成ディレクトリは親の継承ACEを自動取得するため追加ACL操作は不要。
+- mkdir後crash → ディレクトリ削除、rmdir後crash → 空ディレクトリ再作成、でRecoveryする。
+### 7.7 ToolUseGuard
Taskには `requires_external_effect` をPlanner内部属性として持たせる。trueのTaskで、当該iteration中のtool callが0かつ完了証拠がない場合、LLMへ「未実行」を示すsystem-generated observationを追加して再試行する。同一失敗fingerprintが5回に達したらTaskをfailedにする。ただし、Policy EngineのdenyおよびSandbox backendの`UnauthorizedAccessException`は`RetryAllowed=false`のterminal Tool resultへ変換する。Runtimeはobserverへ結果を1回記録した直後にTaskをfailedへ遷移させ、LLMへ再投入しない。
---
## 8. Token Planner設計
@@ -711,7 +721,7 @@ errorは内部codeとlocalized messageを分離し、ログ/auditにはcode、MC
## 22. Codex実装制約
- 新規NuGet dependencyを勝手に追加しない。追加時は標準ライブラリで不能な理由を示す。
- MCP Toolを `agent_run` / `agent_status` 以外に増やさない。
-- Internal ToolをOSコマンド名ごとに増やさず、定義済み5群に収める。
+- Internal ToolをOSコマンド名ごとに増やさず、定義済み6群に収める。
- Legacy HTTP+SSE transport、WebSocket、独自transportを追加しない。
- Streamable HTTPをnon-loopbackへbind可能にしない。
- 認証/TLSを「便利だから」という理由でv1へ追加しない。
diff --git a/doc/Specifications.md b/doc/Specifications.md
index cc04762..5d21037 100644
--- a/doc/Specifications.md
+++ b/doc/Specifications.md
@@ -343,6 +343,7 @@ Context要約を開始する利用率閾値は設定可能でなければならã
| FS-03 | symlink、junction、その他reparse pointを経由した許可範囲外への脱出を禁止する。 |
| FS-04 | write権限だけではdeleteできない。 |
| FS-05 | ACLとAppContainerの両方で強制可能な形にする。 |
+| FS-06 | ディレクトリ作成 (mkdir) はwrite権限で許可する。ディレクトリ削除 (rmdir) はdelete権限で許可するが、空ディレクトリのみとする。ディレクトリ列挙 (list) はread権限で許可する。 |
### 11.3 Process Policy
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()!;
diff --git a/tests/Agent.Core.Tests/InternalToolTests.cs b/tests/Agent.Core.Tests/InternalToolTests.cs
index e452cab..2015702 100644
--- a/tests/Agent.Core.Tests/InternalToolTests.cs
+++ b/tests/Agent.Core.Tests/InternalToolTests.cs
@@ -6,9 +6,9 @@ namespace Agent.Core.Tests;
public sealed class InternalToolTests
{
[Fact]
- public void Catalog_exposes_exactly_five_strict_tool_groups()
+ public void Catalog_exposes_six_strict_tool_groups()
{
- Assert.Equal(["file_search", "file_read", "file_edit", "process_run", "task_update"], InternalToolCatalog.All.Select(x => x.Name));
+ Assert.Equal(["file_search", "file_read", "file_edit", "process_run", "task_update", "dir"], InternalToolCatalog.All.Select(x => x.Name));
foreach (InternalToolDefinition tool in InternalToolCatalog.All) Assert.False(tool.Parameters.GetProperty("additionalProperties").GetBoolean());
}
@@ -86,4 +86,40 @@ public sealed class InternalToolTests
handler.EndTask(scope);
Assert.Throws<InvalidOperationException>(() => handler.Execute(scope, add.RootElement));
}
+
+ [Theory]
+ [InlineData("dir", "{}")]
+ [InlineData("dir", """{"path":"x","operation":"invalid"}""")]
+ [InlineData("dir", """{"path":"x","operation":"mkdir","extra":1}""")]
+ [InlineData("dir", """{"operation":"mkdir"}""")]
+ public void Dir_malformed_arguments_are_rejected(string tool, string json)
+ {
+ using JsonDocument document = JsonDocument.Parse(json);
+ Assert.Throws<ToolValidationException>(() => InternalToolValidator.Validate(tool, document.RootElement));
+ }
+
+ [Fact]
+ public void Dir_catalog_definition_has_required_fields_and_strict_schema()
+ {
+ InternalToolDefinition definition = InternalToolCatalog.Get("dir");
+ Assert.Equal("dir", definition.Name);
+ Assert.False(definition.Parameters.GetProperty("additionalProperties").GetBoolean());
+ Assert.Equal(["path", "operation"], definition.Parameters.GetProperty("required").EnumerateArray().Select(r => r.GetString()!).ToArray());
+ Assert.Equal(["mkdir", "rmdir", "list"], definition.Parameters.GetProperty("properties").GetProperty("operation").GetProperty("enum").EnumerateArray().Select(v => v.GetString()!).ToArray());
+ }
+
+ [Fact]
+ public async Task Dir_dispatched_to_fake_backend_has_external_effect()
+ {
+ FakeInternalToolBackend backend = new();
+ InternalToolDispatcher dispatcher = new(new DenyByDefaultPolicy(new HashSet<string>(["dir"], StringComparer.Ordinal)), backend);
+ using JsonDocument document = JsonDocument.Parse("""{"path":"C:\\tmp\\sub","operation":"mkdir"}""");
+
+ ToolExecutionResult result = await dispatcher.DispatchAsync(Guid.NewGuid(), "dir", document.RootElement, TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.True(result.ExternalEffect);
+ Assert.Single(backend.Calls);
+ Assert.Equal("dir", backend.Calls[0].Name);
+ }
}
diff --git a/tests/Agent.Core.Tests/OllamaClientTests.cs b/tests/Agent.Core.Tests/OllamaClientTests.cs
index aa98764..94ce8cd 100644
--- a/tests/Agent.Core.Tests/OllamaClientTests.cs
+++ b/tests/Agent.Core.Tests/OllamaClientTests.cs
@@ -117,7 +117,7 @@ public sealed class OllamaClientTests
OllamaResult result = await client.ChatWithToolsAsync([new OllamaMessage("user", "read")], null, InternalToolCatalog.All, TestContext.Current.CancellationToken);
using JsonDocument document = JsonDocument.Parse(body!);
- Assert.Equal(5, document.RootElement.GetProperty("tools").GetArrayLength());
+ Assert.Equal(6, document.RootElement.GetProperty("tools").GetArrayLength());
Assert.All(document.RootElement.GetProperty("tools").EnumerateArray(), tool =>
Assert.False(tool.GetProperty("function").GetProperty("parameters").GetProperty("additionalProperties").GetBoolean()));
OllamaToolCall call = Assert.Single(result.ToolCalls!);
diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
index c3c634d..5e682cd 100644
--- a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
+++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
@@ -104,7 +104,7 @@ public sealed class PromptAndRuntimeTests
Assert.Single(backend.Calls);
Assert.Equal("file_edit", backend.Calls[0].Name);
Assert.Equal(2, ollama.CallCount);
- Assert.Equal(5, ollama.ToolCount);
+ Assert.Equal(6, ollama.ToolCount);
}
[Fact]
diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
index 8d920b2..a7197b8 100644
--- a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
+++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
@@ -569,6 +569,141 @@ public sealed class SecurityPolicyTests
Assert.Single(audit.Events, item => item.Event == "acl.restore");
}
+ [Fact]
+ public async Task Dir_mkdir_creates_directory_within_approved_root_and_cleans_up()
+ {
+ using TestDirectory directory = new();
+ string workspace = Path.Combine(directory.Path, "workspace");
+ Directory.CreateDirectory(workspace);
+ string target = Path.Combine(workspace, "subdir");
+ byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(workspace), AccessControlSections.Access).GetSecurityDescriptorBinaryForm();
+ var audit = new MemorySecurityAuditSink();
+ var settings = new WindowsSandboxSettings(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false,
+ new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ 16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
+ var backend = new WindowsToolBackend(settings, audit);
+ Guid taskId = Guid.NewGuid();
+ await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ using JsonDocument mkdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"mkdir"}""");
+
+ ToolExecutionResult result = await backend.ExecuteAsync(taskId, "dir", mkdir.RootElement, TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.True(result.ExternalEffect);
+ Assert.True(Directory.Exists(target));
+
+ await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
+ Assert.True(Directory.Exists(target)); // mkdir result persists after cleanup
+ byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(workspace), AccessControlSections.Access).GetSecurityDescriptorBinaryForm();
+ Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter));
+ Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals")));
+ }
+
+ [Fact]
+ public async Task Dir_rmdir_removes_empty_directory_within_approved_root()
+ {
+ using TestDirectory directory = new();
+ string workspace = Path.Combine(directory.Path, "workspace");
+ Directory.CreateDirectory(workspace);
+ string target = Path.Combine(workspace, "subdir");
+ Directory.CreateDirectory(target);
+ var audit = new MemorySecurityAuditSink();
+ var settings = new WindowsSandboxSettings(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false,
+ new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ 16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
+ var backend = new WindowsToolBackend(settings, audit);
+ Guid taskId = Guid.NewGuid();
+ await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ using JsonDocument rmdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"rmdir"}""");
+
+ ToolExecutionResult result = await backend.ExecuteAsync(taskId, "dir", rmdir.RootElement, TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.True(result.ExternalEffect);
+ Assert.False(Directory.Exists(target));
+ await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
+ Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals")));
+ }
+
+ [Fact]
+ public async Task Dir_rmdir_rejects_non_empty_directory()
+ {
+ using TestDirectory directory = new();
+ string workspace = Path.Combine(directory.Path, "workspace");
+ Directory.CreateDirectory(workspace);
+ string target = Path.Combine(workspace, "subdir");
+ Directory.CreateDirectory(target);
+ File.WriteAllText(Path.Combine(target, "child.txt"), "content");
+ var audit = new MemorySecurityAuditSink();
+ var settings = new WindowsSandboxSettings(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false,
+ new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ 16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
+ var backend = new WindowsToolBackend(settings, audit);
+ Guid taskId = Guid.NewGuid();
+ await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ using JsonDocument rmdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"rmdir"}""");
+
+ await Assert.ThrowsAsync<ToolValidationException>(async () => await backend.ExecuteAsync(taskId, "dir", rmdir.RootElement, TestContext.Current.CancellationToken));
+
+ Assert.True(Directory.Exists(target)); // directory still exists
+ await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
+ }
+
+ [Fact]
+ public async Task Dir_list_returns_entries_in_approved_directory()
+ {
+ using TestDirectory directory = new();
+ string workspace = Path.Combine(directory.Path, "workspace");
+ Directory.CreateDirectory(workspace);
+ Directory.CreateDirectory(Path.Combine(workspace, "child-dir"));
+ File.WriteAllText(Path.Combine(workspace, "child.txt"), "content");
+ var audit = new MemorySecurityAuditSink();
+ var settings = new WindowsSandboxSettings(
+ [new FilePolicyEntry(workspace, FileCapability.Read)], [], false,
+ new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ 16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
+ var backend = new WindowsToolBackend(settings, audit);
+ Guid taskId = Guid.NewGuid();
+ await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ using JsonDocument list = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(workspace)}},"operation":"list"}""");
+
+ ToolExecutionResult result = await backend.ExecuteAsync(taskId, "dir", list.RootElement, TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.False(result.ExternalEffect);
+ Assert.Contains("child-dir", result.Output);
+ Assert.Contains("child.txt", result.Output);
+ await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
+ }
+
+ [Fact]
+ public async Task Dir_mkdir_outside_approved_root_is_denied()
+ {
+ using TestDirectory directory = new();
+ using TestDirectory outside = new();
+ string workspace = Path.Combine(directory.Path, "workspace");
+ Directory.CreateDirectory(workspace);
+ var audit = new MemorySecurityAuditSink();
+ var settings = new WindowsSandboxSettings(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], false,
+ new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ 16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
+ var backend = new WindowsToolBackend(settings, audit);
+ Guid taskId = Guid.NewGuid();
+ await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ string badTarget = Path.Combine(outside.Path, "escaped");
+ using JsonDocument mkdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(badTarget)}},"operation":"mkdir"}""");
+
+ ToolExecutionResult result = await backend.ExecuteAsync(taskId, "dir", mkdir.RootElement, TestContext.Current.CancellationToken);
+
+ Assert.False(result.Success);
+ Assert.False(result.RetryAllowed);
+ await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
+ }
+
[Fact(Timeout = 60_000)]
public async Task Wfp_enforces_actual_appcontainer_loopback_deny_allow_and_port_scope()
{