diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/Agent.Core.Tests/ConfigurationTests.cs | 45 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/InternalToolTests.cs | 2 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/PromptAndRuntimeTests.cs | 11 | ||||
| -rw-r--r-- | tests/Agent.Windows.Tests/SecurityPolicyTests.cs | 144 |
4 files changed, 196 insertions, 6 deletions
diff --git a/tests/Agent.Core.Tests/ConfigurationTests.cs b/tests/Agent.Core.Tests/ConfigurationTests.cs index 2c52b0c..79c746d 100644 --- a/tests/Agent.Core.Tests/ConfigurationTests.cs +++ b/tests/Agent.Core.Tests/ConfigurationTests.cs @@ -1,5 +1,6 @@ using Agent.App.Cli; using Agent.App.Configuration; +using Agent.Core.Skills; namespace Agent.Core.Tests; @@ -43,16 +44,52 @@ public sealed class ConfigurationTests } [Fact] - public void SkillLoader_supports_legacy_markdown_and_standard_skill_directories() + public void SkillLoader_loads_standard_skill_directories_only() { using var directory = new TestDirectory(); + // Legacy single markdown — should be ignored (not loaded). directory.File("skills/legacy.md", "legacy skill"); - directory.File("skills/python-via-uv/SKILL.md", "standard skill"); + // Standard skill directory with frontmatter. + directory.File("skills/python-via-uv/SKILL.md", "---\nname: python-via-uv\ndescription: Python execution skill.\n---\n# Python via uv\nInstructions here."); + // Non-SKILL.md files in skill directory should not be loaded as skills. directory.File("skills/python-via-uv/reference.md", "must not be loaded as a skill"); - string[] skills = SkillLoader.Load(Path.Combine(directory.Path, "skills")); + var skills = SkillLoader.Load(Path.Combine(directory.Path, "skills")); - Assert.Equal(["legacy skill", "standard skill"], skills); + Assert.Single(skills); + Assert.Equal("python-via-uv", skills[0].Name); + Assert.Equal("Python execution skill.", skills[0].Description); + Assert.Contains("Instructions here.", skills[0].Instructions); + } + + [Fact] + public void SkillLoader_rejects_missing_frontmatter() + { + using var directory = new TestDirectory(); + directory.File("skills/no-frontmatter/SKILL.md", "# No frontmatter\nJust body."); + + Assert.Throws<SkillLoadException>(() => SkillLoader.Load(Path.Combine(directory.Path, "skills"))); + } + + [Fact] + public void SkillLoader_rejects_name_mismatch() + { + using var directory = new TestDirectory(); + directory.File("skills/wrong-name/SKILL.md", "---\nname: different-name\ndescription: A skill.\n---\nBody."); + + Assert.Throws<SkillLoadException>(() => SkillLoader.Load(Path.Combine(directory.Path, "skills"))); + } + + [Fact] + public void SkillLoader_rejects_duplicate_names() + { + using var directory = new TestDirectory(); + directory.File("skills/dup-skill/SKILL.md", "---\nname: dup-skill\ndescription: First.\n---\nBody1."); + // Create a second directory with the same skill name (via symlink or junction is not needed; + // just create another dir that also has name: dup-skill in frontmatter). + directory.File("skills/dup-skill-copy/SKILL.md", "---\nname: dup-skill\ndescription: Second.\n---\nBody2."); + + Assert.Throws<SkillLoadException>(() => SkillLoader.Load(Path.Combine(directory.Path, "skills"))); } private static string CreateValidConfiguration(TestDirectory directory) diff --git a/tests/Agent.Core.Tests/InternalToolTests.cs b/tests/Agent.Core.Tests/InternalToolTests.cs index 2015702..c157458 100644 --- a/tests/Agent.Core.Tests/InternalToolTests.cs +++ b/tests/Agent.Core.Tests/InternalToolTests.cs @@ -19,6 +19,8 @@ public sealed class InternalToolTests [InlineData("file_search", "{\"root\":\".\",\"mode\":\"text\",\"query\":\"x\",\"max_results\":1001}")] [InlineData("process_run", "{\"executable\":\"x\",\"args\":\"bad\",\"cwd\":\".\"}")] [InlineData("task_update", "{\"operation\":\"add\",\"description\":\"missing estimate\"}")] + [InlineData("file_edit", "{\"path\":\"a\",\"operation\":\"rename\"}")] + [InlineData("file_edit", "{\"path\":\"a\",\"operation\":\"rename\",\"new_path\":\"\"}")] public void Malformed_arguments_are_rejected(string tool, string json) { using JsonDocument document = JsonDocument.Parse(json); diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs index 5e682cd..27308ef 100644 --- a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs +++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs @@ -2,6 +2,7 @@ using Agent.Core.Ollama; using Agent.Core.Prompts; using Agent.Core.Runtime; using Agent.Core.Sessions; +using Agent.Core.Skills; using Agent.Core.Tasks; using Agent.Core.Tools; using System.Text.Json; @@ -13,12 +14,18 @@ public sealed class PromptAndRuntimeTests [Fact] public void Prompt_order_is_fixed_and_request_is_last() { + var availableSkills = new List<AgentSkill> + { + new("root", "test-skill", "A test skill.", "skill instructions") + }; IReadOnlyList<OllamaMessage> messages = PromptComposer.Compose(new PromptContext( - "fixed", "user system", "ja", ["skill"], "capability", "graph", "summary", + "fixed", "user system", "ja", availableSkills, [], "capability", "graph", "summary", [new ConversationMessage("user", "old"), new ConversationMessage("assistant", "reply")], "new request", ["image"])); - Assert.Equal(["fixed", "user system", "Respond in locale: ja.", "skill", "capability", "graph", "summary", "old", "reply", "new request"], messages.Select(x => x.Content)); + // Check that the skill catalog (name + description) is present, not the full instructions. + Assert.Contains(messages, m => m.Content.Contains("test-skill") && m.Content.Contains("A test skill.")); + Assert.DoesNotContain(messages, m => m.Content == "skill instructions"); Assert.Equal("system", messages[0].Role); Assert.Equal("user", messages[^1].Role); Assert.Equal(["image"], messages[^1].Images); diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs index a7197b8..9f13f56 100644 --- a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs +++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs @@ -704,6 +704,150 @@ public sealed class SecurityPolicyTests await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); } + [Fact] + public async Task Tail_rejects_binary_file() + { + using TestDirectory directory = new(); + string workspace = Path.Combine(directory.Path, "workspace"); + Directory.CreateDirectory(workspace); + string binaryFile = Path.Combine(workspace, "binary.dat"); + File.WriteAllBytes(binaryFile, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, ..new byte[100]]); + 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 tail = JsonDocument.Parse($$"""{"root":{{JsonSerializer.Serialize(binaryFile)}},"mode":"tail","query":"","tail_lines":10}"""); + + await Assert.ThrowsAsync<ToolValidationException>(async () => await backend.ExecuteAsync(taskId, "file_search", tail.RootElement, TestContext.Current.CancellationToken)); + + await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); + } + + [Theory] + [InlineData("utf8")] + [InlineData("utf8bom")] + [InlineData("utf16le")] + [InlineData("utf16be")] + public async Task Tail_accepts_text_files(string encoding) + { + using TestDirectory directory = new(); + string workspace = Path.Combine(directory.Path, "workspace"); + Directory.CreateDirectory(workspace); + string textFile = Path.Combine(workspace, "text.txt"); + string content = "line1\nline2\nline3\n"; + byte[] bytes = encoding switch + { + "utf8" => System.Text.Encoding.UTF8.GetBytes(content), + "utf8bom" => [.. System.Text.Encoding.UTF8.Preamble, .. System.Text.Encoding.UTF8.GetBytes(content)], + "utf16le" => [.. System.Text.Encoding.Unicode.Preamble, .. System.Text.Encoding.Unicode.GetBytes(content)], + "utf16be" => [.. System.Text.Encoding.BigEndianUnicode.Preamble, .. System.Text.Encoding.BigEndianUnicode.GetBytes(content)], + _ => throw new ArgumentException(encoding) + }; + File.WriteAllBytes(textFile, bytes); + 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 tail = JsonDocument.Parse($$"""{"root":{{JsonSerializer.Serialize(textFile)}},"mode":"tail","query":"","tail_lines":10}"""); + + ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_search", tail.RootElement, TestContext.Current.CancellationToken); + + Assert.True(result.Success); + await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task File_edit_rename_moves_file_within_approved_root() + { + using TestDirectory directory = new(); + string workspace = Path.Combine(directory.Path, "workspace"); + Directory.CreateDirectory(workspace); + string source = Path.Combine(workspace, "source.txt"); + string dest = Path.Combine(workspace, "dest.txt"); + File.WriteAllText(source, "rename-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 rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}"""); + + ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_edit", rename.RootElement, TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.True(result.ExternalEffect); + Assert.False(File.Exists(source)); + Assert.True(File.Exists(dest)); + Assert.Equal("rename-content", File.ReadAllText(dest)); + await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); + Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals"))); + } + + [Fact] + public async Task File_edit_rename_rejects_existing_destination() + { + using TestDirectory directory = new(); + string workspace = Path.Combine(directory.Path, "workspace"); + Directory.CreateDirectory(workspace); + string source = Path.Combine(workspace, "source.txt"); + string dest = Path.Combine(workspace, "dest.txt"); + File.WriteAllText(source, "source-content"); + File.WriteAllText(dest, "dest-content"); + 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); + using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}"""); + + await Assert.ThrowsAsync<ToolValidationException>(async () => await backend.ExecuteAsync(taskId, "file_edit", rename.RootElement, TestContext.Current.CancellationToken)); + + Assert.True(File.Exists(source)); // source still exists + Assert.True(File.Exists(dest)); // dest untouched + await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task File_edit_rename_rejects_outside_approved_root() + { + using TestDirectory directory = new(); + using TestDirectory outside = new(); + string workspace = Path.Combine(directory.Path, "workspace"); + Directory.CreateDirectory(workspace); + string source = Path.Combine(workspace, "source.txt"); + string dest = Path.Combine(outside.Path, "escaped.txt"); + File.WriteAllText(source, "content"); + 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); + using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}"""); + + ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_edit", rename.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() { |
