diff options
Diffstat (limited to 'tests/Agent.Core.Tests')
| -rw-r--r-- | tests/Agent.Core.Tests/ConfigurationTests.cs | 250 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/PromptAndRuntimeTests.cs | 27 |
2 files changed, 264 insertions, 13 deletions
diff --git a/tests/Agent.Core.Tests/ConfigurationTests.cs b/tests/Agent.Core.Tests/ConfigurationTests.cs index e588825..954792b 100644 --- a/tests/Agent.Core.Tests/ConfigurationTests.cs +++ b/tests/Agent.Core.Tests/ConfigurationTests.cs @@ -3,6 +3,8 @@ using Agent.App.Configuration; using Agent.App; using Agent.Core.Localization; using Agent.Core.Skills; +using Agent.App.Sandbox; +using Agent.Windows.Security; namespace Agent.Core.Tests; @@ -16,7 +18,7 @@ public sealed class ConfigurationTests AgentConfiguration result = ConfigurationLoader.Load(config); Assert.Equal(1, result.SchemaVersion); Assert.Equal("model", result.Ollama.Model); - Assert.Equal("deny", result.Sandbox.Network.Mode); + Assert.Equal("none", result.Sandbox.Network.Access); } [Fact] @@ -93,14 +95,13 @@ public sealed class ConfigurationTests application: language: ja sandbox: - filesystem: - read: [C:/local-only] + workspaces: [C:/local-only] """); AgentConfiguration result = ConfigurationLoader.Load(config); Assert.Equal("ja", result.Application.Language); - Assert.Equal(["C:/local-only"], result.Sandbox.Filesystem.Read); + Assert.Equal(["C:/local-only"], result.Sandbox.Workspaces); Assert.Equal("model", result.Ollama.Model); } @@ -115,21 +116,243 @@ public sealed class ConfigurationTests } [Fact] - public void Execute_file_without_process_approval_reports_canonical_keys() + public void Legacy_filesystem_and_process_schema_is_rejected_with_migration_diagnostic() { using var directory = new TestDirectory(); string config = CreateValidConfiguration(directory); - string executable = directory.File("tools/tool.exe", "fixture").Replace('\\', '/'); directory.File("agent.local.yaml", $$""" sandbox: filesystem: - execute: [{{executable}}] + execute: [C:/legacy/tool.exe] + process: + approvals: [] + """); + + ConfigurationException error = Assert.Throws<ConfigurationException>(() => ConfigurationLoader.Load(config)); + Assert.Contains("Legacy sandbox.filesystem/sandbox.process", error.Message, StringComparison.Ordinal); + Assert.Contains("sandbox resolve", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Semantic_sandbox_rejects_readonly_write_mount_and_conflicting_network_endpoints() + { + using var directory = new TestDirectory(); + string config = CreateValidConfiguration(directory); + directory.File("agent.local.yaml", """ + sandbox: + profile: readonly + mounts: + - path: C:/data + access: read_write + network: + access: none + local_endpoints: + - port: 11434 """); ConfigurationException error = Assert.Throws<ConfigurationException>(() => ConfigurationLoader.Load(config)); - Assert.Contains("sandbox.filesystem.execute", error.Message, StringComparison.Ordinal); - Assert.Contains("sandbox.process.approvals", error.Message, StringComparison.Ordinal); - Assert.Contains(Path.GetFullPath(executable), error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("readonly", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Management_commands_require_config_and_capture_options() + { + Assert.Throws<CommandLineException>(() => CommandLine.Parse(["sandbox", "resolve"])); + CommandLine approve = CommandLine.Parse(["executable", "approve", "python", "--path", "C:/python.exe", "--no-runtime", "--yes", "--config", "agent.yaml"]); + Assert.Equal(AppCommand.ExecutableApprove, approve.Command); + Assert.Equal("python", approve.Alias); + Assert.True(approve.NoRuntime); + Assert.True(approve.Yes); + } + + [Fact] + public void Executable_alias_ambiguity_is_reported_without_automatic_selection() + { + using var directory = new TestDirectory(); + string first = Path.Combine(directory.Path, "first"); + string second = Path.Combine(directory.Path, "second"); + Directory.CreateDirectory(first); + Directory.CreateDirectory(second); + File.WriteAllText(Path.Combine(first, "fixture.exe"), "first"); + File.WriteAllText(Path.Combine(second, "fixture.exe"), "second"); + string? original = Environment.GetEnvironmentVariable("PATH"); + try + { + Environment.SetEnvironmentVariable("PATH", string.Join(Path.PathSeparator, first, second)); + + IReadOnlyList<string> candidates = ExecutableResolver.ResolveCandidates("fixture", null); + + Assert.Equal(2, candidates.Count); + Assert.Contains(candidates, item => item.Equals(Path.Combine(first, "fixture.exe"), StringComparison.OrdinalIgnoreCase)); + Assert.Contains(candidates, item => item.Equals(Path.Combine(second, "fixture.exe"), StringComparison.OrdinalIgnoreCase)); + } + finally { Environment.SetEnvironmentVariable("PATH", original); } + } + + [Fact] + public void Shell_classification_accepts_only_the_documented_command_interpreters() + { + Assert.True(ExecutableResolver.IsSupportedShell("C:/Windows/System32/cmd.exe")); + Assert.True(ExecutableResolver.IsSupportedShell("C:/Program Files/PowerShell/7/pwsh.exe")); + Assert.False(ExecutableResolver.IsSupportedShell("C:/tools/python.exe")); + } + + [Fact] + public void Resolution_confirmation_is_invalidated_by_config_fingerprint_change() + { + using var directory = new TestDirectory(); + string configPath = directory.File("agent.yaml", "schema_version: 1"); + string workspace = Path.Combine(directory.Path, "workspace"); + Directory.CreateDirectory(workspace); + string stores = Path.Combine(directory.Path, "stores"); + var configuration = new AgentConfiguration + { + Sandbox = new SandboxOptions { Profile = "development", Workspaces = [workspace] } + }; + var resolutionStore = new SandboxResolutionStore(configPath, stores, protectDirectory: false); + var approvalStore = new ExecutableApprovalStore(Path.Combine(stores, "approvals.json"), protectDirectory: false); + var resolver = new SandboxEnvironmentResolver(configPath, configuration, resolutionStore, approvalStore); + _ = resolver.Confirm(); + _ = resolver.Compile(Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups")); + + configuration.Sandbox.Resources.Profile = "extended"; + ConfigurationException error = Assert.Throws<ConfigurationException>(() => + resolver.Compile(Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"))); + Assert.Contains("changed after confirmation", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Approval_store_never_refreshes_a_changed_executable_identity() + { + using var directory = new TestDirectory(); + string configPath = directory.File("agent.yaml", "schema_version: 1"); + string executable = directory.File("fixture.exe", "first"); + string stores = Path.Combine(directory.Path, "stores"); + var configuration = new AgentConfiguration(); + configuration.Sandbox.Execution.Executables.Add("fixture"); + var resolutionStore = new SandboxResolutionStore(configPath, stores, protectDirectory: false); + var approvalStore = new ExecutableApprovalStore(Path.Combine(stores, "approvals.json"), protectDirectory: false); + approvalStore.Approve(new("fixture", "executable", executable, ResolvedPath.Resolve(executable), + EntryExecutablePolicy.ComputeSha256(executable), null, DateTimeOffset.UtcNow)); + var resolver = new SandboxEnvironmentResolver(configPath, configuration, resolutionStore, approvalStore); + _ = resolver.Confirm(); + File.WriteAllText(executable, "second"); + + ConfigurationException error = Assert.Throws<ConfigurationException>(() => + resolver.Compile(Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"))); + Assert.Contains("SHA-256 changed", error.Message, StringComparison.Ordinal); + Assert.Single(approvalStore.List()); + Assert.True(approvalStore.Revoke("fixture")); + Assert.Empty(approvalStore.List()); + Assert.Empty(Directory.EnumerateFiles(stores, "*.tmp")); + } + + [Fact] + public void Approval_store_rejects_malformed_identity_records() + { + using var directory = new TestDirectory(); + string storePath = directory.File("stores/approvals.json", """ + { + "schemaVersion": 1, + "approvals": [{ + "alias": "Python With Spaces", + "classification": "unknown", + "logicalPath": "python.exe", + "resolvedPath": "relative/python.exe", + "sha256": "not-a-hash", + "runtimeRoot": null, + "approvedAt": "2026-08-12T00:00:00Z" + }] + } + """); + var store = new ExecutableApprovalStore(storePath, protectDirectory: false); + + Assert.Throws<ConfigurationException>(() => store.List()); + } + + [Fact] + public void Narrow_nested_mount_is_rejected_instead_of_silently_widened() + { + using var directory = new TestDirectory(); + string configPath = directory.File("agent.yaml", "schema_version: 1"); + string workspace = Path.Combine(directory.Path, "workspace"); + string nested = Path.Combine(workspace, "read-only-data"); + Directory.CreateDirectory(nested); + string stores = Path.Combine(directory.Path, "stores"); + var configuration = new AgentConfiguration + { + Sandbox = new SandboxOptions + { + Profile = "development", + Workspaces = [workspace], + Mounts = [new MountOptions { Path = nested, Access = "read_only" }] + } + }; + var resolver = new SandboxEnvironmentResolver(configPath, configuration, + new SandboxResolutionStore(configPath, stores, protectDirectory: false), + new ExecutableApprovalStore(Path.Combine(stores, "approvals.json"), protectDirectory: false)); + _ = resolver.Confirm(); + + ConfigurationException error = Assert.Throws<ConfigurationException>(() => + resolver.Compile(Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"))); + Assert.Contains("cannot be enforced", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Profile_network_and_resource_configuration_compile_to_effective_policy() + { + using var directory = new TestDirectory(); + string configPath = directory.File("agent.yaml", "schema_version: 1"); + string workspace = Path.Combine(directory.Path, "workspace"); + Directory.CreateDirectory(workspace); + string stores = Path.Combine(directory.Path, "stores"); + var configuration = new AgentConfiguration + { + Sandbox = new SandboxOptions + { + Profile = "development", + Workspaces = [workspace], + Network = new NetworkOptions { Access = "local", LocalEndpoints = [new LocalEndpointOptions { Port = 11434 }] }, + Resources = new ResourceOptions { Profile = "constrained", ProcessLimit = 6, ExecutionTimeoutSeconds = 450 } + } + }; + var resolver = new SandboxEnvironmentResolver(configPath, configuration, + new SandboxResolutionStore(configPath, stores, protectDirectory: false), + new ExecutableApprovalStore(Path.Combine(stores, "approvals.json"), protectDirectory: false)); + _ = resolver.Confirm(); + + Agent.Windows.Tools.EffectiveSandboxPolicy policy = resolver.Compile( + Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups")); + + Assert.Contains(workspace, policy.ArtifactRoots, StringComparer.OrdinalIgnoreCase); + Assert.Contains(policy.Files, item => item.Path.Equals(workspace, StringComparison.OrdinalIgnoreCase) && + item.Capabilities == (FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute)); + Assert.Equal(6u, policy.JobLimits.MaxProcesses); + Assert.Equal(1024UL * 1024 * 1024, policy.JobLimits.MaxJobMemoryBytes); + Assert.Equal(450, policy.ProcessTimeoutSeconds); + Assert.Contains(policy.NetworkAllowances, item => item.Host == "127.0.0.1" && item.Ports.SequenceEqual([11434])); + Assert.Contains(policy.NetworkAllowances, item => item.Host == "::1" && item.Ports.SequenceEqual([11434])); + } + + [Fact] + public void Readonly_profile_never_creates_an_artifact_root_or_execute_capability() + { + using var directory = new TestDirectory(); + string configPath = directory.File("agent.yaml", "schema_version: 1"); + string workspace = Path.Combine(directory.Path, "workspace"); + Directory.CreateDirectory(workspace); + string stores = Path.Combine(directory.Path, "stores"); + var configuration = new AgentConfiguration { Sandbox = new SandboxOptions { Profile = "readonly", Workspaces = [workspace] } }; + var resolver = new SandboxEnvironmentResolver(configPath, configuration, + new SandboxResolutionStore(configPath, stores, protectDirectory: false), + new ExecutableApprovalStore(Path.Combine(stores, "approvals.json"), protectDirectory: false)); + _ = resolver.Confirm(); + + Agent.Windows.Tools.EffectiveSandboxPolicy policy = resolver.Compile( + Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups")); + + Assert.Empty(policy.ArtifactRoots); + Assert.Contains(policy.Files, item => item.Path.Equals(workspace, StringComparison.OrdinalIgnoreCase) && item.Capabilities == FileCapability.Read); } [Fact] @@ -160,6 +383,13 @@ public sealed class ConfigurationTests string message = Program.FormatStartupError(["--lang", "ja"], new CommandLineException("bad option")); Assert.Contains("コマンドラインが不正です", message, StringComparison.Ordinal); Assert.Contains("bad option", message, StringComparison.Ordinal); + + string sandbox = Program.FormatStartupError(["--lang", "ja"], new ConfigurationException( + "internal", code: "executable.hash_changed", category: "sandbox", target: "C:/Python/python.exe", + resolution: "internal resolution")); + Assert.Contains("SHA-256が変更されました", sandbox, StringComparison.Ordinal); + Assert.Contains("明示的に再承認", sandbox, StringComparison.Ordinal); + Assert.DoesNotContain("internal resolution", sandbox, StringComparison.Ordinal); } [Fact] diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs index 082ea9b..b7f27e7 100644 --- a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs +++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs @@ -275,7 +275,7 @@ public sealed class PromptAndRuntimeTests { TaskRegistry registry = new(); var ollama = new ThinkRetryOllamaClient(succeedOnRetry: true); - var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox")); + var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("execution-only-marker", "en", [], "sandbox")); TaskSnapshot task = registry.Enqueue("analyze", null, 0.7); registry.Transition(task.TaskId, AgentTaskState.Planning); @@ -291,6 +291,8 @@ public sealed class PromptAndRuntimeTests message.Role == "system" && message.Content.Contains("one implementation-and-verification Task", StringComparison.Ordinal)); Assert.Contains(ollama.PlanningMessages[1], message => message.Role == "system" && message.Content.Contains("Do not call or execute any Tool", StringComparison.Ordinal)); + Assert.DoesNotContain(ollama.PlanningMessages.SelectMany(static messages => messages), message => + message.Content.Contains("execution-only-marker", StringComparison.Ordinal)); } [Fact] @@ -311,6 +313,23 @@ public sealed class PromptAndRuntimeTests } [Fact] + public async Task Split_coding_plan_is_rejected_and_retried_with_specific_correction() + { + TaskRegistry registry = new(); + var ollama = new ThinkRetryOllamaClient(succeedOnRetry: true, semanticSplitFirst: true); + var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox")); + TaskSnapshot task = registry.Enqueue("implement and test", null, null); + registry.Transition(task.TaskId, AgentTaskState.Planning); + + await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); + + Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? completed)); + Assert.Equal(AgentTaskState.Completed, completed!.State); + Assert.Contains(ollama.PlanningMessages[1], message => message.Role == "system" && + message.Content.Contains("cannot depend on a separate file_edit Task", StringComparison.Ordinal)); + } + + [Fact] public async Task Failed_context_summary_does_not_delete_original_context() { var sessions = new SessionStore(); @@ -610,7 +629,7 @@ public sealed class PromptAndRuntimeTests } } - private sealed class ThinkRetryOllamaClient(bool succeedOnRetry) : IOllamaClient + private sealed class ThinkRetryOllamaClient(bool succeedOnRetry, bool semanticSplitFirst = false) : IOllamaClient { public List<bool?> ThinkOverrides { get; } = []; public List<double?> Temperatures { get; } = []; @@ -623,7 +642,9 @@ public sealed class PromptAndRuntimeTests ThinkOverrides.Add(thinkOverride); Temperatures.Add(temperatureOverride); PlanningMessages.Add(messages); - string content = ThinkOverrides.Count == 2 && succeedOnRetry + string content = ThinkOverrides.Count == 1 && semanticSplitFirst + ? """{"tasks":[{"description":"edit","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":true,"required_tools":["file_edit"]},{"description":"verify and correct","dependencies":[0],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":true,"required_tools":["file_edit","process_run"]}]}""" + : ThinkOverrides.Count == 2 && succeedOnRetry ? """ Here is the requested plan. |
