diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-12 05:44:54 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-12 05:44:54 +0900 |
| commit | b56614c814b904c224315a11f9686f0d47abd3d3 (patch) | |
| tree | e6ae4feb852ea4492bf947eabba1e11ab157686a /tests | |
| parent | 98b6a2aeedd591058c1d81e401d24f3ded162926 (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')
| -rw-r--r-- | tests/Agent.Core.Tests/ConfigurationTests.cs | 250 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/PromptAndRuntimeTests.cs | 27 | ||||
| -rw-r--r-- | tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj | 1 | ||||
| -rw-r--r-- | tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs | 4 | ||||
| -rw-r--r-- | tests/Agent.Windows.Tests/SecurityPolicyTests.cs | 555 |
5 files changed, 744 insertions, 93 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. diff --git a/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj b/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj index 0b45401..7888f53 100644 --- a/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj +++ b/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj @@ -7,6 +7,7 @@ </PropertyGroup> <ItemGroup> <ProjectReference Include="..\..\src\Agent.Windows\Agent.Windows.csproj" /> + <ProjectReference Include="..\..\src\Agent.App\Agent.App.csproj" /> </ItemGroup> <ItemGroup> <PackageReference Include="Microsoft.NET.Test.Sdk" /> diff --git a/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs index d025eac..6686833 100644 --- a/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs +++ b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs @@ -59,7 +59,7 @@ internal static class CrashScenarioBootstrap string auditPath = Path.Combine(root, "crash-audit.jsonl"); var processAudit = new JsonLinesSecurityAuditSink(auditPath); var launcher = new RestrictedProcessLauncher( - new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), processAudit); + new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), processAudit); string escapedAudit = auditPath.Replace("'", "''", StringComparison.Ordinal); string crashError = Path.Combine(root, "crash-error.txt").Replace("'", "''", StringComparison.Ordinal); string watchdogScript = $"$d=[DateTime]::UtcNow.AddSeconds(10); while([DateTime]::UtcNow -lt $d){{ if((Test-Path -LiteralPath '{escapedAudit}') -and (Select-String -LiteralPath '{escapedAudit}' -SimpleMatch '\"event\":\"process.start\"' -Quiet)){{ Stop-Process -Id {Environment.ProcessId} -Force; exit 0 }}; Start-Sleep -Milliseconds 25 }}; Set-Content -LiteralPath '{crashError}' -Value 'Sandbox process did not start before crash'; Stop-Process -Id {Environment.ProcessId} -Force"; @@ -68,7 +68,7 @@ internal static class CrashScenarioBootstrap FileName = powershell, UseShellExecute = false, CreateNoWindow = true, ArgumentList = { "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", watchdogScript } }); - _ = launcher.Run(taskId, powershell, + _ = launcher.Run(taskId, "powershell", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30"], Environment.SystemDirectory, TimeSpan.FromMinutes(1)); break; diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs index da6b325..09ed61d 100644 --- a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs +++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs @@ -9,12 +9,36 @@ using Agent.Windows.Network; using Agent.Windows.Tools; using Agent.Core.Tools; using System.Text.Json; +using Agent.App.Sandbox; +using Agent.App.Configuration; namespace Agent.Windows.Tests; public sealed class SecurityPolicyTests { [Fact] + public void Approval_store_uses_atomic_replace_and_a_user_only_dacl() + { + using TestDirectory directory = new(); + string storePath = Path.Combine(directory.Path, "protected", "executable-approvals.json"); + var store = new ExecutableApprovalStore(storePath); + string executable = Environment.ProcessPath!; + store.Approve(new("self", "executable", executable, executable, + EntryExecutablePolicy.ComputeSha256(executable), null, DateTimeOffset.UtcNow)); + + Assert.Single(store.List()); + Assert.Empty(Directory.EnumerateFiles(Path.GetDirectoryName(storePath)!, "*.tmp")); + DirectorySecurity security = FileSystemAclExtensions.GetAccessControl( + new DirectoryInfo(Path.GetDirectoryName(storePath)!), AccessControlSections.Access); + Assert.True(security.AreAccessRulesProtected); + using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); + var rules = security.GetAccessRules(true, false, typeof(System.Security.Principal.SecurityIdentifier)) + .OfType<FileSystemAccessRule>().ToArray(); + Assert.NotEmpty(rules); + Assert.All(rules, rule => Assert.Equal(identity.User, rule.IdentityReference)); + } + + [Fact] public void Process_timeout_uses_configured_default_and_rejects_higher_values() { using JsonDocument omitted = JsonDocument.Parse("{}"); @@ -52,11 +76,12 @@ public sealed class SecurityPolicyTests } [Fact] - public void Reparse_escape_is_rejected() + public void Reparse_escape_is_resolved_and_denied_by_effective_root() { using TestDirectory approved = new(); using TestDirectory outside = new(); string link = Path.Combine(approved.Path, "escape"); + File.WriteAllText(Path.Combine(outside.Path, "secret.txt"), "secret"); using Process junction = Process.Start(new ProcessStartInfo { FileName = Environment.GetEnvironmentVariable("ComSpec")!, @@ -70,8 +95,7 @@ public sealed class SecurityPolicyTests try { - SandboxPolicyException error = Assert.Throws<SandboxPolicyException>(() => policy.IsAllowed(Path.Combine(link, "secret.txt"), FileCapability.Read)); - Assert.Equal("path.reparse", error.Code); + Assert.False(policy.IsAllowed(Path.Combine(link, "secret.txt"), FileCapability.Read)); } finally { @@ -80,68 +104,131 @@ public sealed class SecurityPolicyTests } [Fact] - public void Symlink_chain_escape_is_rejected() + public void Symlink_chain_is_resolved_and_denied_by_effective_root() { using TestDirectory approved = new(); using TestDirectory outside = new(); string second = Path.Combine(approved.Path, "second-link"); string first = Path.Combine(approved.Path, "first-link"); + File.WriteAllText(Path.Combine(outside.Path, "secret.txt"), "secret"); Directory.CreateSymbolicLink(second, outside.Path); Directory.CreateSymbolicLink(first, second); PathPolicy policy = new([new FilePolicyEntry(approved.Path, FileCapability.Read)]); - SandboxPolicyException error = Assert.Throws<SandboxPolicyException>(() => - policy.IsAllowed(Path.Combine(first, "secret.txt"), FileCapability.Read)); + Assert.False(policy.IsAllowed(Path.Combine(first, "secret.txt"), FileCapability.Read)); + Directory.Delete(first); + Directory.Delete(second); + } + + [Fact] + public void Reparse_resolution_cycle_is_rejected_fail_closed() + { + using TestDirectory directory = new(); + string first = Path.Combine(directory.Path, "cycle-a"); + string second = Path.Combine(directory.Path, "cycle-b"); + Directory.CreateSymbolicLink(first, second); + Directory.CreateSymbolicLink(second, first); + + SandboxPolicyException error = Assert.Throws<SandboxPolicyException>(() => ResolvedPath.Resolve(first)); - Assert.Equal("path.reparse", error.Code); + Assert.Equal("path.reparse_cycle", error.Code); Directory.Delete(first); Directory.Delete(second); } [Fact] - public void Executable_requires_approved_path_and_hash_and_shell_toggle() + public void Confirmed_resolution_is_invalidated_when_a_junction_target_changes() { + using TestDirectory directory = new(); + string firstTarget = Path.Combine(directory.Path, "target-one"); + string secondTarget = Path.Combine(directory.Path, "target-two"); + string junctionPath = Path.Combine(directory.Path, "workspace-link"); + Directory.CreateDirectory(firstTarget); + Directory.CreateDirectory(secondTarget); + CreateJunction(junctionPath, firstTarget); + string configPath = Path.Combine(directory.Path, "agent.yaml"); + File.WriteAllText(configPath, "schema_version: 1"); + string stores = Path.Combine(directory.Path, "stores"); + var configuration = new AgentConfiguration + { + Sandbox = new SandboxOptions { Profile = "development", Workspaces = [junctionPath] } + }; + var resolver = new SandboxEnvironmentResolver(configPath, configuration, + new SandboxResolutionStore(configPath, stores, protectDirectory: false), + new ExecutableApprovalStore(Path.Combine(stores, "approvals.json"), protectDirectory: false)); + _ = resolver.Confirm(); + Directory.Delete(junctionPath); + CreateJunction(junctionPath, secondTarget); + + ConfigurationException error = Assert.Throws<ConfigurationException>(() => resolver.Compile( + Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"))); + + Assert.Equal("sandbox.resolution_changed", error.Code); + Directory.Delete(junctionPath); + } + + [Fact] + public void Entry_executable_requires_alias_path_and_hash_identity() + { + using TestDirectory roots = new(); string executable = Environment.ProcessPath!; - string hash = ExecutablePolicy.ComputeSha256(executable); - Assert.Equal(hash, new ExecutablePolicy([new ExecutableApproval(executable, hash)], true).Verify(executable).Sha256); - Assert.Throws<SandboxPolicyException>(() => new ExecutablePolicy([], true).Verify(executable)); + string hash = EntryExecutablePolicy.ComputeSha256(executable); + var approval = new ApprovedEntryExecutable("test", executable, hash, false, null); + Assert.Equal(hash, new EntryExecutablePolicy([approval], [], []).VerifyEntry("test").Sha256); + Assert.Throws<SandboxPolicyException>(() => new EntryExecutablePolicy([approval], [], []).VerifyEntry(executable)); + Assert.Throws<SandboxPolicyException>(() => new EntryExecutablePolicy([], [], []).VerifyEntry(executable)); + Assert.Throws<SandboxPolicyException>(() => new EntryExecutablePolicy([], [], []) + .VerifyEntry(Path.Combine(Environment.SystemDirectory, "cmd.exe"))); string badHash = new string(hash[0] == '0' ? '1' : '0', 1) + hash[1..]; - Assert.Throws<SandboxPolicyException>(() => new ExecutablePolicy([new ExecutableApproval(executable, badHash)], true).Verify(executable)); - - string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); - Assert.Throws<SandboxPolicyException>(() => new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], false).Verify(powershell)); + Assert.Throws<SandboxPolicyException>(() => new EntryExecutablePolicy([approval with { Sha256 = badHash }], [], []).VerifyEntry("test")); + + string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe"); + var shell = new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null); + Assert.Equal(EntryExecutableClassification.ApprovedShellExecutable, + new EntryExecutablePolicy([shell], [], []).VerifyEntry("powershell").Classification); + + string runtimeRoot = Path.Combine(roots.Path, "runtime"); + string artifactRoot = Path.Combine(roots.Path, "workspace"); + Directory.CreateDirectory(runtimeRoot); + Directory.CreateDirectory(artifactRoot); + string runtimeComponent = Path.Combine(runtimeRoot, "component.exe"); + string workspaceArtifact = Path.Combine(artifactRoot, "artifact.exe"); + File.WriteAllText(runtimeComponent, "runtime"); + File.WriteAllText(workspaceArtifact, "artifact"); + var rootsPolicy = new EntryExecutablePolicy([], [runtimeRoot], [artifactRoot]); + Assert.Equal(EntryExecutableClassification.RuntimeComponent, rootsPolicy.VerifyEntry(runtimeComponent).Classification); + Assert.Equal(EntryExecutableClassification.WorkspaceArtifact, rootsPolicy.VerifyEntry(workspaceArtifact).Classification); + Assert.Equal(EntryExecutableClassification.WorkspaceArtifact, rootsPolicy.VerifyEntry("artifact.exe", artifactRoot).Classification); } [Fact] - public void Restricted_parent_cannot_start_unbrokered_descendant() + public void Approved_entry_can_start_descendants_in_the_same_job_tree() { using TestDirectory directory = new(); string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); var audit = new MemorySecurityAuditSink(); var launcher = new RestrictedProcessLauncher( - new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), audit); + new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), audit); string copied = Path.Combine(directory.Path, "renamed-child.exe"); File.Copy(Environment.GetEnvironmentVariable("ComSpec")!, copied); - string[] markers = Enumerable.Range(1, 4).Select(index => Path.Combine(directory.Path, $"descendant-{index}.txt")).ToArray(); + string[] markers = Enumerable.Range(1, 2).Select(index => Path.Combine(directory.Path, $"descendant-{index}.txt")).ToArray(); string[] commands = [ $"$ErrorActionPreference='Stop'; Start-Process -FilePath $env:ComSpec -ArgumentList '/d','/c','echo compromised > \"{markers[0]}\"' -Wait", - $"$ErrorActionPreference='Stop'; [Diagnostics.Process]::Start($env:ComSpec, '/d /c echo compromised ^> \"{markers[1]}\"').WaitForExit()", - $"$ErrorActionPreference='Stop'; & $env:ComSpec /d /c 'echo compromised > \"{markers[2]}\"'", - $"$ErrorActionPreference='Stop'; Start-Process -FilePath '{copied}' -ArgumentList '/d','/c','echo compromised > \"{markers[3]}\"' -Wait" + $"$ErrorActionPreference='Stop'; Start-Process -FilePath '{copied}' -ArgumentList '/d','/c','echo compromised > \"{markers[1]}\"' -Wait" ]; foreach (string command in commands) { - RestrictedProcessResult result = launcher.Run(Guid.NewGuid(), powershell, - ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], directory.Path, TimeSpan.FromSeconds(30)); - Assert.NotEqual(0u, result.ExitCode); + RestrictedProcessResult result = launcher.Run(Guid.NewGuid(), "powershell", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], directory.Path, TimeSpan.FromSeconds(30), + null, null, TestContext.Current.CancellationToken); + Assert.Equal(0u, result.ExitCode); } - Assert.All(markers, marker => Assert.False(File.Exists(marker))); - Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["child_process_policy"] == "restricted"); - Assert.Contains(audit.Events, item => item.Event == "process.exit" && item.Data["exit_code"] != "0"); - Assert.Equal(commands.Length, audit.Events.Count(item => item.Event == "policy.deny" && item.Data["child_process_policy"] == "restricted")); + Assert.All(markers, marker => Assert.True(File.Exists(marker))); + Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["descendant_policy"] == "lpac_inherited"); + Assert.Equal(commands.Length, audit.Events.Count(item => item.Event == "process.exit" && item.Data["exit_code"] == "0")); } [Fact] @@ -150,11 +237,11 @@ public sealed class SecurityPolicyTests string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); var audit = new MemorySecurityAuditSink(); var launcher = new RestrictedProcessLauncher( - new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), audit); + new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), audit); - Assert.Throws<TimeoutException>(() => launcher.Run(Guid.NewGuid(), powershell, + Assert.Throws<TimeoutException>(() => launcher.Run(Guid.NewGuid(), "powershell", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30"], - Environment.SystemDirectory, TimeSpan.FromMilliseconds(250))); + Environment.SystemDirectory, TimeSpan.FromMilliseconds(250), null, null, TestContext.Current.CancellationToken)); SecurityAuditEvent timeout = Assert.Single(audit.Events, item => item.Event == "process.timeout"); int pid = int.Parse(timeout.Data["pid"], System.Globalization.CultureInfo.InvariantCulture); @@ -162,19 +249,58 @@ public sealed class SecurityPolicyTests } [Fact] - public void Dotnet_managed_entry_requires_its_own_path_and_hash_approval() + public void Job_process_limit_applies_to_the_entire_descendant_tree() + { + string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); + var launcher = new RestrictedProcessLauncher( + new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), + new MemorySecurityAuditSink(), jobLimits: new SandboxJobLimits(1, 512UL * 1024 * 1024)); + const string script = "$denied=$false; try { $p=Start-Process -PassThru -ErrorAction Stop -FilePath $env:ComSpec -ArgumentList '/d','/c','exit 0'; $p.WaitForExit() } catch { $denied=$true }; if(-not $denied){ exit 46 }"; + + RestrictedProcessResult result = launcher.Run(Guid.NewGuid(), "powershell", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], Environment.SystemDirectory, + TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken); + + Assert.Equal(0u, result.ExitCode); + } + + [Fact] + public void Cancellation_terminates_the_entire_sandbox_job_without_orphan() + { + using TestDirectory directory = new(); + string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); + string childPidFile = Path.Combine(directory.Path, "child.pid"); + var audit = new MemorySecurityAuditSink(); + var launcher = new RestrictedProcessLauncher( + new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), audit); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + + Assert.Throws<OperationCanceledException>(() => launcher.Run(Guid.NewGuid(), "powershell", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + $"$p=Start-Process -PassThru -FilePath $env:ComSpec -ArgumentList '/d','/c','ping -n 30 127.0.0.1 > nul'; Set-Content -LiteralPath '{childPidFile}' -Value $p.Id; Start-Sleep -Seconds 30"], + directory.Path, TimeSpan.FromSeconds(60), null, null, cancellation.Token)); + + Assert.True(File.Exists(childPidFile)); + int childPid = int.Parse(File.ReadAllText(childPidFile), System.Globalization.CultureInfo.InvariantCulture); + Assert.Throws<ArgumentException>(() => Process.GetProcessById(childPid)); + Assert.Contains(audit.Events, item => item.Event == "process.cancel" && item.Outcome == "terminated"); + } + + [Fact] + public void Dotnet_managed_entry_requires_runtime_or_artifact_containment() { string dotnet = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet", "dotnet.exe"); string managedAssembly = typeof(SecurityPolicyTests).Assembly.Location; var audit = new MemorySecurityAuditSink(); var launcher = new RestrictedProcessLauncher( - new ExecutablePolicy([new ExecutableApproval(dotnet, ExecutablePolicy.ComputeSha256(dotnet))], true), audit); + new EntryExecutablePolicy([new ApprovedEntryExecutable("dotnet", dotnet, EntryExecutablePolicy.ComputeSha256(dotnet), false, null)], [], []), audit); SandboxPolicyException denied = Assert.Throws<SandboxPolicyException>(() => launcher.Run( - Guid.NewGuid(), dotnet, [managedAssembly], Path.GetDirectoryName(managedAssembly)!, TimeSpan.FromSeconds(5))); + Guid.NewGuid(), "dotnet", [managedAssembly], Path.GetDirectoryName(managedAssembly)!, TimeSpan.FromSeconds(5), + null, null, TestContext.Current.CancellationToken)); - Assert.Equal("process.path_denied", denied.Code); - Assert.Contains(audit.Events, item => item.Event == "policy.deny" && item.Data["code"] == "process.path_denied"); + Assert.Equal("process.managed_entry_unknown", denied.Code); + Assert.Contains(audit.Events, item => item.Event == "policy.deny" && item.Data["code"] == "process.managed_entry_unknown"); } [Fact] @@ -285,15 +411,94 @@ public sealed class SecurityPolicyTests try { var launcher = new RestrictedProcessLauncher( - new ExecutablePolicy([new ExecutableApproval(commandProcessor, ExecutablePolicy.ComputeSha256(commandProcessor))], true), + new EntryExecutablePolicy([new ApprovedEntryExecutable("cmd", commandProcessor, EntryExecutablePolicy.ComputeSha256(commandProcessor), true, null)], [], []), audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024)); - RestrictedProcessResult result = launcher.Run(taskId, commandProcessor, ["/d", "/c", $"echo APP_OK>{marker} & echo CAPTURE_OK"], directory.Path, TimeSpan.FromSeconds(30)); + RestrictedProcessResult result = launcher.Run(taskId, "cmd", ["/d", "/c", $"echo APP_OK>{marker} & echo CAPTURE_OK"], + directory.Path, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken); Assert.Equal(0u, result.ExitCode); Assert.Equal("APP_OK", File.ReadAllText(marker).Trim()); Assert.Contains("CAPTURE_OK", result.StandardOutput, StringComparison.Ordinal); - Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["app_container_sid"] == profile.Sid.Value); + Assert.Contains(audit.Events, item => item.Event == "process.start" && + item.Data["app_container_sid"] == profile.Sid.Value && item.Data["token_app_container_sid"] == profile.Sid.Value && + item.Data["token_is_app_container"] == bool.TrueString && item.Data["process_in_job"] == bool.TrueString && + item.Data["all_application_packages_opt_out"] == bool.TrueString); + } + finally + { + acl.Restore(taskId, snapshot); + } + } + + [Fact] + public void Lpac_baseline_allows_registry_read_but_denies_registry_write_and_com_activation() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using TestDirectory directory = new(); + string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe"); + string registryName = "AgentSandboxMcpDenied" + Guid.NewGuid().ToString("N"); + string secretName = "AGENT_SANDBOX_SECRET_" + Guid.NewGuid().ToString("N"); + Environment.SetEnvironmentVariable(secretName, "must-not-cross-broker-boundary"); + var audit = new MemorySecurityAuditSink(); + TaskAclManager acl = new(profile.Sid, audit); + Guid taskId = Guid.NewGuid(); + AclSnapshot snapshot = acl.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write); + try + { + var launcher = new RestrictedProcessLauncher( + new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), + audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024)); + string script = $"$ErrorActionPreference='Stop'; $p=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion').ProductName; " + + $"$writeDenied=$false; try {{ New-Item -Path 'HKCU:\\Software\\{registryName}' -Force | Out-Null }} catch {{ $writeDenied=$true }}; " + + "$comDenied=$false; try { $null=New-Object -ComObject Scripting.FileSystemObject } catch { $comDenied=$true }; " + + $"if(-not $writeDenied -or -not $comDenied -or $env:{secretName}){{ exit 43 }}; Write-Output \"REGISTRY_READ=$p;REGISTRY_WRITE_DENIED=$writeDenied;COM_DENIED=$comDenied;BROKER_SECRET_INHERITED=$([bool]$env:{secretName})\""; + + RestrictedProcessResult result = launcher.Run(taskId, "powershell", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], directory.Path, TimeSpan.FromSeconds(30), + null, null, TestContext.Current.CancellationToken); + + Assert.True(result.ExitCode == 0u, + $"LPAC baseline probe exited {result.ExitCode}. stdout: {result.StandardOutput}; stderr: {result.StandardError}"); + Assert.Contains("REGISTRY_READ=", result.StandardOutput, StringComparison.Ordinal); + Assert.Contains("REGISTRY_WRITE_DENIED=True", result.StandardOutput, StringComparison.Ordinal); + Assert.Contains("COM_DENIED=True", result.StandardOutput, StringComparison.Ordinal); + Assert.Contains("BROKER_SECRET_INHERITED=False", result.StandardOutput, StringComparison.Ordinal); + } + finally + { + Microsoft.Win32.Registry.CurrentUser.DeleteSubKeyTree($"Software\\{registryName}", throwOnMissingSubKey: false); + Environment.SetEnvironmentVariable(secretName, null); + acl.Restore(taskId, snapshot); + } + } + + [Fact] + public void Approved_powershell_starts_unapproved_cmd_descendant_inside_lpac_job() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using TestDirectory directory = new(); + string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe"); + string marker = Path.Combine(directory.Path, "cmd-descendant.txt"); + var audit = new MemorySecurityAuditSink(); + TaskAclManager acl = new(profile.Sid, audit); + Guid taskId = Guid.NewGuid(); + AclSnapshot snapshot = acl.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write); + try + { + var launcher = new RestrictedProcessLauncher( + new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), + audit, profile.Sid, new SandboxJobLimits(4, 512UL * 1024 * 1024)); + string command = $"& $env:ComSpec /d /c exit 0; if($LASTEXITCODE -ne 0){{ exit 44 }}; Set-Content -LiteralPath '{marker}' -Value 'LPAC_CHILD'"; + + RestrictedProcessResult result = launcher.Run(taskId, "powershell", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], directory.Path, TimeSpan.FromSeconds(30), + null, null, TestContext.Current.CancellationToken); + + Assert.Equal(0u, result.ExitCode); + Assert.Equal("LPAC_CHILD", File.ReadAllText(marker).Trim()); + Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["token_is_app_container"] == bool.TrueString && + item.Data["process_in_job"] == bool.TrueString && item.Data["descendant_policy"] == "lpac_inherited"); } finally { @@ -302,6 +507,178 @@ public sealed class SecurityPolicyTests } [Fact] + public void Approved_python314_starts_python_subprocess_inside_lpac_job() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using TestDirectory directory = new(); + string uvPythonRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "uv", "python"); + string python = Directory.EnumerateFiles(uvPythonRoot, "python.exe", SearchOption.AllDirectories) + .Where(path => path.Contains("cpython-3.14", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(path => path, StringComparer.OrdinalIgnoreCase).First(); + string resolvedPython = ResolvedPath.Resolve(python); + string runtimeRoot = Path.GetDirectoryName(resolvedPython)!; + string marker = Path.Combine(directory.Path, "python-child.txt"); + string breakawayMarker = Path.Combine(directory.Path, "python-breakaway-child.txt"); + var audit = new MemorySecurityAuditSink(); + TaskAclManager acl = new(profile.Sid, audit); + Guid taskId = Guid.NewGuid(); + var snapshots = new List<AclSnapshot>(); + try + { + snapshots.Add(acl.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write)); + snapshots.Add(acl.Grant(taskId, runtimeRoot, FileCapability.Read | FileCapability.Execute)); + var launcher = new RestrictedProcessLauncher( + new EntryExecutablePolicy([new ApprovedEntryExecutable("python", resolvedPython, EntryExecutablePolicy.ComputeSha256(resolvedPython), false, runtimeRoot)], + [runtimeRoot], []), audit, profile.Sid, new SandboxJobLimits(4, 256UL * 1024 * 1024)); + static string ChildProbe(string path) => "import ctypes,ctypes.wintypes as w; from pathlib import Path; " + + "k=ctypes.windll.kernel32; a=ctypes.windll.advapi32; k.GetCurrentProcess.restype=w.HANDLE; " + + "a.OpenProcessToken.argtypes=[w.HANDLE,w.DWORD,ctypes.POINTER(w.HANDLE)]; a.GetTokenInformation.argtypes=[w.HANDLE,w.DWORD,ctypes.c_void_p,w.DWORD,ctypes.POINTER(w.DWORD)]; " + + "a.ConvertSidToStringSidW.argtypes=[ctypes.c_void_p,ctypes.POINTER(w.LPWSTR)]; k.IsProcessInJob.argtypes=[w.HANDLE,w.HANDLE,ctypes.POINTER(w.BOOL)]; k.LocalFree.argtypes=[ctypes.c_void_p]; token=w.HANDLE(); " + + "ok=a.OpenProcessToken(k.GetCurrentProcess(),8,ctypes.byref(token)); " + + "size=w.DWORD(); a.GetTokenInformation(token,31,None,0,ctypes.byref(size)); buf=ctypes.create_string_buffer(size.value); " + + "ok=ok and a.GetTokenInformation(token,31,buf,size,ctypes.byref(size)); sid=ctypes.c_void_p.from_buffer(buf).value; " + + "text=w.LPWSTR(); ok=ok and a.ConvertSidToStringSidW(sid,ctypes.byref(text)); in_job=w.BOOL(); ok=ok and k.IsProcessInJob(k.GetCurrentProcess(),None,ctypes.byref(in_job)); " + + $"Path({JsonSerializer.Serialize(path)}).write_text(text.value+'|'+str(bool(in_job.value)),encoding='utf-8'); k.LocalFree(text); raise SystemExit(0 if ok else 48)"; + string childCode = ChildProbe(marker); + string breakawayCode = ChildProbe(breakawayMarker); + string parentCode = $"import subprocess,sys; " + + $"result=subprocess.run([sys.executable,'-c',{JsonSerializer.Serialize(childCode)}]); " + + "denied=False; " + + $"\ntry: breakaway=subprocess.run([sys.executable,'-c',{JsonSerializer.Serialize(breakawayCode)}],creationflags=0x01000000); denied=(breakaway.returncode != 0)" + + "\nexcept OSError: denied=True" + + $"\nif not denied and __import__('pathlib').Path({JsonSerializer.Serialize(breakawayMarker)}).exists(): denied=__import__('pathlib').Path({JsonSerializer.Serialize(breakawayMarker)}).read_text(encoding='utf-8').endswith('|True')" + + "\nmemory_denied=False" + + "\ntry: payload=bytearray(512*1024*1024)" + + "\nexcept MemoryError: memory_denied=True" + + "\nraise SystemExit(0 if result.returncode == 0 and denied and memory_denied else 45)"; + + RestrictedProcessResult result = launcher.Run(taskId, "python", ["-c", parentCode], directory.Path, TimeSpan.FromSeconds(30), + null, null, TestContext.Current.CancellationToken); + + Assert.True(result.ExitCode == 0u, $"Python LPAC subprocess exited {result.ExitCode}: {result.StandardError}"); + Assert.Equal(profile.Sid.Value + "|True", File.ReadAllText(marker)); + Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["token_is_app_container"] == bool.TrueString && + item.Data["process_in_job"] == bool.TrueString); + } + finally + { + foreach (AclSnapshot snapshot in snapshots.AsEnumerable().Reverse()) acl.Restore(taskId, snapshot); + } + } + + [Fact] + public void Development_workspace_artifact_runs_as_an_entry_without_an_approval() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using TestDirectory directory = new(); + string artifact = Path.Combine(directory.Path, "workspace-artifact.exe"); + string marker = Path.Combine(directory.Path, "artifact-entry.txt"); + File.Copy(Environment.GetEnvironmentVariable("ComSpec")!, artifact); + var audit = new MemorySecurityAuditSink(); + var acl = new TaskAclManager(profile.Sid, audit); + Guid taskId = Guid.NewGuid(); + AclSnapshot snapshot = acl.Grant(taskId, directory.Path, + FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute); + try + { + var launcher = new RestrictedProcessLauncher(new EntryExecutablePolicy([], [], [directory.Path]), audit, profile.Sid); + + RestrictedProcessResult result = launcher.Run(taskId, artifact, ["/d", "/c", $"echo ARTIFACT_OK>{marker}"], + directory.Path, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken); + + Assert.Equal(0u, result.ExitCode); + Assert.Equal("ARTIFACT_OK", File.ReadAllText(marker).Trim()); + Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["entry_classification"] == "WorkspaceArtifact"); + } + finally { acl.Restore(taskId, snapshot); } + } + + [Fact] + public void Readonly_workspace_denies_write_delete_and_execute_inside_the_lpac_tree() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using TestDirectory directory = new(); + string data = Path.Combine(directory.Path, "data.txt"); + string artifact = Path.Combine(directory.Path, "artifact.exe"); + File.WriteAllText(data, "keep"); + File.Copy(Environment.GetEnvironmentVariable("ComSpec")!, artifact); + string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe"); + var audit = new MemorySecurityAuditSink(); + var acl = new TaskAclManager(profile.Sid, audit); + Guid taskId = Guid.NewGuid(); + AclSnapshot snapshot = acl.Grant(taskId, directory.Path, FileCapability.Read); + try + { + var launcher = new RestrictedProcessLauncher( + new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), + audit, profile.Sid); + string script = $"$writeDenied=$false; try {{ Set-Content -LiteralPath '{data}' -Value changed -ErrorAction Stop }} catch {{ $writeDenied=$true }}; " + + $"$deleteDenied=$false; try {{ Remove-Item -LiteralPath '{data}' -Force -ErrorAction Stop }} catch {{ $deleteDenied=$true }}; " + + $"$executeDenied=$false; try {{ & '{artifact}' /d /c exit 0; if($LASTEXITCODE -ne 0){{ $executeDenied=$true }} }} catch {{ $executeDenied=$true }}; " + + "if(-not $writeDenied -or -not $deleteDenied -or -not $executeDenied){ exit 47 }"; + + RestrictedProcessResult result = launcher.Run(taskId, "powershell", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], directory.Path, + TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken); + + Assert.Equal(0u, result.ExitCode); + Assert.Equal("keep", File.ReadAllText(data)); + } + finally { acl.Restore(taskId, snapshot); } + } + + [Fact] + public void Approved_dotnet_build_and_test_commands_create_confined_process_trees() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using TestDirectory directory = new(); + string project = Path.Combine(directory.Path, "ProcessTree.proj"); + string buildMarker = Path.Combine(directory.Path, "build-child.txt"); + string testMarker = Path.Combine(directory.Path, "test-child.txt"); + File.WriteAllText(project, $$""" + <Project> + <Target Name="Build"> + <WriteLinesToFile File="{{buildMarker}}" Lines="BUILD_CHILD" Overwrite="true" /> + </Target> + <Target Name="VSTest"> + <WriteLinesToFile File="{{testMarker}}" Lines="TEST_CHILD" Overwrite="true" /> + </Target> + </Project> + """); + string dotnet = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet", "dotnet.exe"); + string runtimeRoot = Path.GetDirectoryName(dotnet)!; + var audit = new MemorySecurityAuditSink(); + var acl = new TaskAclManager(profile.Sid, audit); + Guid taskId = Guid.NewGuid(); + var snapshots = new List<AclSnapshot>(); + try + { + snapshots.Add(acl.Grant(taskId, directory.Path, + FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute)); + snapshots.Add(acl.Grant(taskId, runtimeRoot, FileCapability.Read | FileCapability.Execute)); + // System32 owns a protected DACL. Do not rewrite it; successful cmd descendants below + // are the enforcement check for its existing LPAC read/execute access. + var launcher = new RestrictedProcessLauncher( + new EntryExecutablePolicy([new ApprovedEntryExecutable("dotnet", dotnet, EntryExecutablePolicy.ComputeSha256(dotnet), false, runtimeRoot)], + [runtimeRoot], [directory.Path]), audit, profile.Sid, new SandboxJobLimits(16, 1024UL * 1024 * 1024)); + + RestrictedProcessResult build = launcher.Run(taskId, "dotnet", ["build", project, "--no-restore", "-nologo"], + directory.Path, TimeSpan.FromSeconds(60), null, null, TestContext.Current.CancellationToken); + RestrictedProcessResult test = launcher.Run(taskId, "dotnet", ["test", project, "--no-restore", "-nologo"], + directory.Path, TimeSpan.FromSeconds(60), null, null, TestContext.Current.CancellationToken); + + Assert.True(build.ExitCode == 0u, $"dotnet build failed. stdout: {build.StandardOutput} stderr: {build.StandardError}"); + Assert.True(test.ExitCode == 0u, $"dotnet test failed. stdout: {test.StandardOutput} stderr: {test.StandardError}"); + Assert.Equal("BUILD_CHILD", File.ReadAllText(buildMarker).Trim()); + Assert.Equal("TEST_CHILD", File.ReadAllText(testMarker).Trim()); + } + finally + { + foreach (AclSnapshot snapshot in snapshots.AsEnumerable().Reverse()) acl.Restore(taskId, snapshot); + } + } + + [Fact] public void File_transaction_commit_rollback_and_hash_guard_leave_no_journal() { using TestDirectory directory = new(); @@ -567,9 +944,9 @@ public sealed class SecurityPolicyTests File.WriteAllText(target, "before"); 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"), + var settings = new EffectiveSandboxPolicy( + [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [], + new SandboxJobLimits(), "none", [], 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(); @@ -606,9 +983,9 @@ public sealed class SecurityPolicyTests 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"), + var settings = new EffectiveSandboxPolicy( + [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [], + new SandboxJobLimits(), "none", [], 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(); @@ -637,9 +1014,9 @@ public sealed class SecurityPolicyTests 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"), + var settings = new EffectiveSandboxPolicy( + [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [], + new SandboxJobLimits(), "none", [], 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(); @@ -665,17 +1042,20 @@ public sealed class SecurityPolicyTests 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"), + var settings = new EffectiveSandboxPolicy( + [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [], + new SandboxJobLimits(), "none", [], 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, Guid.NewGuid(), 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)); + ToolExecutionResult result = await backend.ExecuteAsync(taskId, "dir", rmdir.RootElement, TestContext.Current.CancellationToken); + Assert.False(result.Success); + Assert.True(result.RetryAllowed); + Assert.Equal("tool.input_invalid", result.ErrorCode); Assert.True(Directory.Exists(target)); // directory still exists await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); } @@ -689,9 +1069,9 @@ public sealed class SecurityPolicyTests 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"), + var settings = new EffectiveSandboxPolicy( + [new FilePolicyEntry(workspace, FileCapability.Read)], [], [], [], + new SandboxJobLimits(), "none", [], 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(); @@ -715,9 +1095,9 @@ public sealed class SecurityPolicyTests 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"), + var settings = new EffectiveSandboxPolicy( + [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], [], [], + new SandboxJobLimits(), "none", [], 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(); @@ -741,17 +1121,20 @@ public sealed class SecurityPolicyTests 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"), + var settings = new EffectiveSandboxPolicy( + [new FilePolicyEntry(workspace, FileCapability.Read)], [], [], [], + new SandboxJobLimits(), "none", [], 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, Guid.NewGuid(), 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)); + ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_search", tail.RootElement, TestContext.Current.CancellationToken); + Assert.False(result.Success); + Assert.True(result.RetryAllowed); + Assert.Equal("tool.input_invalid", result.ErrorCode); await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); } @@ -777,9 +1160,9 @@ public sealed class SecurityPolicyTests }; 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"), + var settings = new EffectiveSandboxPolicy( + [new FilePolicyEntry(workspace, FileCapability.Read)], [], [], [], + new SandboxJobLimits(), "none", [], 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(); @@ -802,9 +1185,9 @@ public sealed class SecurityPolicyTests 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"), + var settings = new EffectiveSandboxPolicy( + [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [], + new SandboxJobLimits(), "none", [], 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(); @@ -833,17 +1216,20 @@ public sealed class SecurityPolicyTests 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"), + var settings = new EffectiveSandboxPolicy( + [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], [], [], + new SandboxJobLimits(), "none", [], 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, Guid.NewGuid(), 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)); + ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_edit", rename.RootElement, TestContext.Current.CancellationToken); + Assert.False(result.Success); + Assert.True(result.RetryAllowed); + Assert.Equal("tool.input_invalid", result.ErrorCode); Assert.True(File.Exists(source)); // source still exists Assert.True(File.Exists(dest)); // dest untouched await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); @@ -860,9 +1246,9 @@ public sealed class SecurityPolicyTests 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"), + var settings = new EffectiveSandboxPolicy( + [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], [], [], + new SandboxJobLimits(), "none", [], 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(); @@ -883,8 +1269,8 @@ public sealed class SecurityPolicyTests string curl = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "curl.exe"); var audit = new MemorySecurityAuditSink(); var launcher = new RestrictedProcessLauncher( - new ExecutablePolicy([new ExecutableApproval(curl, ExecutablePolicy.ComputeSha256(curl))], true), - audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024)); + new EntryExecutablePolicy([new ApprovedEntryExecutable("curl", curl, EntryExecutablePolicy.ComputeSha256(curl), false, null)], [], []), + audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024), allowNetwork: true); using TestDirectory directory = new(); bool diagnosticExemptionAdded = LoopbackExemption.Ensure(profile.Sid); @@ -945,7 +1331,20 @@ public sealed class SecurityPolicyTests } private static RestrictedProcessResult Curl(RestrictedProcessLauncher launcher, Guid taskId, string curl, string url, string cwd) => - launcher.Run(taskId, curl, ["--noproxy", "*", "--silent", "--show-error", "--max-time", "2", url], cwd, TimeSpan.FromSeconds(10)); + launcher.Run(taskId, "curl", ["--noproxy", "*", "--silent", "--show-error", "--max-time", "2", url], cwd, TimeSpan.FromSeconds(10)); + + private static void CreateJunction(string path, string target) + { + using Process process = Process.Start(new ProcessStartInfo + { + FileName = Environment.GetEnvironmentVariable("ComSpec")!, + UseShellExecute = false, + CreateNoWindow = true, + ArgumentList = { "/d", "/c", "mklink", "/J", path, target } + })!; + process.WaitForExit(); + Assert.Equal(0, process.ExitCode); + } private static byte[] AccessDescriptor(string path) { |
