summaryrefslogtreecommitdiff
path: root/tests/Agent.Core.Tests/ConfigurationTests.cs
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-12 05:44:54 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-12 05:44:54 +0900
commitb56614c814b904c224315a11f9686f0d47abd3d3 (patch)
treee6ae4feb852ea4492bf947eabba1e11ab157686a /tests/Agent.Core.Tests/ConfigurationTests.cs
parent98b6a2aeedd591058c1d81e401d24f3ded162926 (diff)
feat: revise sandbox usability around semantic policy and LPAC process trees
Rewrite the specification and design vocabulary around User Sandbox Configuration, Environment Resolution, Executable Approval, Effective Sandbox Policy, and Windows Enforcement. Move the Internal Tool catalog to the design without treating its count as a requirement, and update README, implementation planning, traceability, audit history, prompts, and the Python skill. Introduce schema_version 1 profiles, workspaces, mounts, executable aliases, network modes, and resource presets. Add local override merging, resolution and approval stores with atomic user-only persistence, fail-closed fingerprints, localized diagnostics, and executable/sandbox management CLI commands. Change Windows execution to LPAC entry approval with inherited descendant confinement. Separate read and execute ACLs, preserve protected OS DACLs, apply Job process/memory/timeout limits to the tree, enforce WFP network modes, prevent credential environment inheritance, disable out-of-tree MSBuild reuse, and harden transactional ACL/recovery cleanup. Isolate planning prompts from execution instructions, reject split coding plans that cannot satisfy task-scoped evidence, preserve configured thinking, and prevent optional task_update failures from corrupting completion checks. Add and update configuration, resolution, approval, LPAC, descendant, readonly/development, Registry/COM, WFP, recovery, .NET/Python/PowerShell process-tree, and Operational E2E coverage. Publish reproducible bundles while preserving deployment-local configuration, prompts, skills, logs, state, approvals, and resolutions.
Diffstat (limited to 'tests/Agent.Core.Tests/ConfigurationTests.cs')
-rw-r--r--tests/Agent.Core.Tests/ConfigurationTests.cs250
1 files changed, 240 insertions, 10 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]