summaryrefslogtreecommitdiff
path: root/tests/Agent.Core.Tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests/Agent.Core.Tests')
-rw-r--r--tests/Agent.Core.Tests/ConfigurationTests.cs250
-rw-r--r--tests/Agent.Core.Tests/PromptAndRuntimeTests.cs27
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.