summaryrefslogtreecommitdiff
path: root/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
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 /tests/Agent.Windows.Tests/SecurityPolicyTests.cs
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を 直接起動する必要がある
Diffstat (limited to 'tests/Agent.Windows.Tests/SecurityPolicyTests.cs')
-rw-r--r--tests/Agent.Windows.Tests/SecurityPolicyTests.cs135
1 files changed, 135 insertions, 0 deletions
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()
{