From ea1c1ea81e937779ae740b736070aeb4ceb84e29 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 11 Sep 2026 11:48:45 +0200 Subject: [PATCH] .NET: Canonicalize Hyperlight sandbox fingerprints Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b9da797e-a9ff-492f-b5c1-8e9c7af1d8dc --- .../HyperlightCodeActProvider.cs | 4 +- .../HyperlightExecuteCodeFunction.cs | 4 +- .../Internal/SandboxExecutor.cs | 183 +++++++++++---- .../ProvideAIContextTests.cs | 104 +++++++++ .../SandboxExecutorTests.cs | 208 ++++++++++++++++++ 5 files changed, 453 insertions(+), 50 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs index d3442a32442..a96c47d3abd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs @@ -75,7 +75,7 @@ public sealed class HyperlightCodeActProvider : AIContextProvider, IDisposable public HyperlightCodeActProvider(HyperlightCodeActProviderOptions? options = null) { this._options = options ?? new HyperlightCodeActProviderOptions(); - this._executor = new SandboxExecutor(this._options); + this._executor = new SandboxExecutor(); if (this._options.Tools is not null) { @@ -280,7 +280,7 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co this._tools.Values.ToList(), this._fileMounts.Values.ToList(), this._allowedDomains.Values.ToList(), - this._options.HostInputDirectory, + this._options, this._toolRegistryVersion); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs index c5e61d89acc..c99eedb1f1f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs @@ -65,7 +65,7 @@ public sealed class HyperlightExecuteCodeFunction : AIFunction, IDisposable public HyperlightExecuteCodeFunction(HyperlightCodeActProviderOptions? options = null) { var effective = options ?? new HyperlightCodeActProviderOptions(); - this._executor = new SandboxExecutor(effective); + this._executor = new SandboxExecutor(); var tools = (effective.Tools?.Where(t => t is not null) ?? []).ToList(); var fileMounts = (effective.FileMounts?.Where(m => m is not null) ?? []).ToList(); @@ -75,7 +75,7 @@ public HyperlightExecuteCodeFunction(HyperlightCodeActProviderOptions? options = tools, fileMounts, allowedDomains, - effective.HostInputDirectory, + effective, toolRegistryVersion: Guid.Empty); this._description = InstructionBuilder.BuildExecuteCodeDescription( diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs index 578fc10734b..4c13b10d60a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs @@ -1,10 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Buffers; using System.Collections.Generic; -using System.Globalization; using System.Linq; -using System.Text; +using System.Security.Cryptography; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -21,7 +21,6 @@ namespace Microsoft.Agents.AI.Hyperlight.Internal; /// internal sealed class SandboxExecutor : IDisposable { - private readonly HyperlightCodeActProviderOptions _options; private readonly SemaphoreSlim _executionLock = new(1, 1); private Sandbox? _sandbox; @@ -29,11 +28,6 @@ internal sealed class SandboxExecutor : IDisposable private string? _lastConfigFingerprint; private bool _disposed; - public SandboxExecutor(HyperlightCodeActProviderOptions options) - { - this._options = options; - } - /// /// Immutable snapshot of provider state at the start of a run. /// Used to build a run-scoped execute_code function that is @@ -45,20 +39,30 @@ public RunSnapshot( IReadOnlyList tools, IReadOnlyList fileMounts, IReadOnlyList allowedDomains, - string? hostInputDirectory, + HyperlightCodeActProviderOptions options, Guid toolRegistryVersion = default) { - this.Tools = tools; - this.FileMounts = fileMounts; - this.AllowedDomains = allowedDomains; - this.HostInputDirectory = hostInputDirectory; + this.Tools = tools.ToList(); + this.FileMounts = fileMounts.ToList(); + this.AllowedDomains = allowedDomains + .Select(domain => new AllowedDomain(domain.Target, domain.Methods?.ToArray())) + .ToList(); + this.Backend = options.Backend; + this.ModulePath = options.ModulePath; + this.HeapSize = options.HeapSize; + this.StackSize = options.StackSize; + this.HostInputDirectory = options.HostInputDirectory; this.ToolRegistryVersion = toolRegistryVersion; this.ConfigFingerprint = ComputeFingerprint( - tools, - fileMounts, - allowedDomains, - hostInputDirectory, - toolRegistryVersion); + this.Tools, + this.FileMounts, + this.AllowedDomains, + this.HostInputDirectory, + toolRegistryVersion, + this.Backend, + this.ModulePath, + this.HeapSize, + this.StackSize); } public IReadOnlyList Tools { get; } @@ -67,6 +71,14 @@ public RunSnapshot( public IReadOnlyList AllowedDomains { get; } + public SandboxBackend Backend { get; } + + public string? ModulePath { get; } + + public string? HeapSize { get; } + + public string? StackSize { get; } + public string? HostInputDirectory { get; } public Guid ToolRegistryVersion { get; } @@ -75,7 +87,7 @@ public RunSnapshot( /// Stable fingerprint of the configuration that materially affects how /// the sandbox must be built. Used by to /// decide whether a previously-built sandbox can be reused or must be - /// rebuilt because tools / mounts / allow-list entries have changed. + /// rebuilt because its capabilities or runtime options have changed. /// public string ConfigFingerprint { get; } @@ -84,35 +96,114 @@ internal static string ComputeFingerprint( IReadOnlyList fileMounts, IReadOnlyList allowedDomains, string? hostInputDirectory, - Guid toolRegistryVersion = default) + Guid toolRegistryVersion = default, + SandboxBackend backend = SandboxBackend.JavaScript, + string? modulePath = null, + string? heapSize = null, + string? stackSize = null) { - var sb = new StringBuilder(); - sb.Append("tools="); - foreach (var name in tools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal)) + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) { - sb.Append(name).Append('|'); + writer.WriteStartObject(); + writer.WriteNumber("backend", (int)backend); + writer.WriteString("modulePath", modulePath); + writer.WriteString("heapSize", heapSize); + writer.WriteString("stackSize", stackSize); + writer.WriteString("hostInputDirectory", hostInputDirectory); + writer.WriteString("toolRegistryVersion", toolRegistryVersion); + + writer.WritePropertyName("tools"); + writer.WriteStartArray(); + foreach (var name in tools.Select(tool => tool.Name).OrderBy(name => name, StringComparer.Ordinal)) + { + writer.WriteStringValue(name); + } + + writer.WriteEndArray(); + + writer.WritePropertyName("fileMounts"); + writer.WriteStartArray(); + foreach (var mount in fileMounts + .OrderBy(mount => mount.MountPath, StringComparer.Ordinal) + .ThenBy(mount => mount.HostPath, StringComparer.Ordinal)) + { + writer.WriteStartObject(); + writer.WriteString("mountPath", mount.MountPath); + writer.WriteString("hostPath", mount.HostPath); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + + var domains = allowedDomains + .Select(domain => ( + domain.Target, + Methods: domain.Methods?.OrderBy(method => method, StringComparer.Ordinal).ToArray())) + .ToList(); + domains.Sort(static (left, right) => + { + var targetComparison = StringComparer.Ordinal.Compare(left.Target, right.Target); + return targetComparison != 0 + ? targetComparison + : CompareStringArrays(left.Methods, right.Methods); + }); + + writer.WritePropertyName("allowedDomains"); + writer.WriteStartArray(); + foreach (var domain in domains) + { + writer.WriteStartObject(); + writer.WriteString("target", domain.Target); + writer.WritePropertyName("methods"); + if (domain.Methods is null) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStartArray(); + foreach (var method in domain.Methods) + { + writer.WriteStringValue(method); + } + + writer.WriteEndArray(); + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); } - sb.Append(";toolVersion=").Append(toolRegistryVersion.ToString("D", CultureInfo.InvariantCulture)); + return Convert.ToHexString(SHA256.HashData(buffer.WrittenSpan)); + } + + private static int CompareStringArrays(string[]? left, string[]? right) + { + if (left is null) + { + return right is null ? 0 : -1; + } - sb.Append(";mounts="); - foreach (var m in fileMounts - .Select(m => m.MountPath + "->" + m.HostPath) - .OrderBy(s => s, StringComparer.Ordinal)) + if (right is null) { - sb.Append(m).Append('|'); + return 1; } - sb.Append(";allow="); - foreach (var d in allowedDomains - .Select(d => d.Target + "/" + (d.Methods is null ? "*" : string.Join(",", d.Methods))) - .OrderBy(s => s, StringComparer.Ordinal)) + var sharedLength = Math.Min(left.Length, right.Length); + for (var index = 0; index < sharedLength; index++) { - sb.Append(d).Append('|'); + var comparison = StringComparer.Ordinal.Compare(left[index], right[index]); + if (comparison != 0) + { + return comparison; + } } - sb.Append(";input=").Append(hostInputDirectory ?? string.Empty); - return sb.ToString(); + return left.Length.CompareTo(right.Length); } } @@ -162,7 +253,7 @@ private void EnsureInitialized(RunSnapshot snapshot) } // Configuration changed (or first run) — dispose the previous sandbox - // so the new one picks up the new tool/mount/allow-list set. + // so the new one picks up the current capabilities and runtime options. this._warmSnapshot?.Dispose(); this._sandbox?.Dispose(); this._warmSnapshot = null; @@ -174,21 +265,21 @@ private void EnsureInitialized(RunSnapshot snapshot) private void BuildAndWarmUp(RunSnapshot snapshot) { var builder = new SandboxBuilder() - .WithBackend(this._options.Backend); + .WithBackend(snapshot.Backend); - if (!string.IsNullOrEmpty(this._options.ModulePath)) + if (!string.IsNullOrEmpty(snapshot.ModulePath)) { - builder = builder.WithModulePath(this._options.ModulePath!); + builder = builder.WithModulePath(snapshot.ModulePath!); } - if (!string.IsNullOrEmpty(this._options.HeapSize)) + if (!string.IsNullOrEmpty(snapshot.HeapSize)) { - builder = builder.WithHeapSize(this._options.HeapSize!); + builder = builder.WithHeapSize(snapshot.HeapSize!); } - if (!string.IsNullOrEmpty(this._options.StackSize)) + if (!string.IsNullOrEmpty(snapshot.StackSize)) { - builder = builder.WithStackSize(this._options.StackSize!); + builder = builder.WithStackSize(snapshot.StackSize!); } var hostInput = snapshot.HostInputDirectory; @@ -221,7 +312,7 @@ private void BuildAndWarmUp(RunSnapshot snapshot) // Backend-specific no-op used to trigger lazy guest runtime initialization // before the warm snapshot is captured. Matches the values used by the // upstream HyperlightSandbox.Extensions.AI CodeExecutionTool reference. - _ = sandbox.Run(this._options.Backend == SandboxBackend.JavaScript ? "void 0;" : "None"); + _ = sandbox.Run(snapshot.Backend == SandboxBackend.JavaScript ? "void 0;" : "None"); this._warmSnapshot = sandbox.Snapshot(); this._sandbox = sandbox; this._lastConfigFingerprint = snapshot.ConfigFingerprint; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs index 21e4273c0cd..726a98d608e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.AI.Hyperlight.Internal; @@ -101,4 +102,107 @@ public async Task ProvideAIContextAsync_AddToolsSameNameReplacement_ChangesSnaps var secondFunction = Assert.IsType(secondContext!.Tools!.First()); Assert.NotEqual(firstFunction.ConfigFingerprint, secondFunction.ConfigFingerprint); } + + [Fact] + public async Task ProvideAIContextAsync_ToolAddRemoveAndClear_ChangeSnapshotFingerprintAsync() + { + // Arrange + using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); + var firstTool = AIFunctionFactory.Create(() => "one", name: "first_tool"); + var secondTool = AIFunctionFactory.Create(() => "two", name: "second_tool"); + var fingerprints = new List(); + + // Act + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddTools(firstTool); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.RemoveTools(firstTool.Name); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddTools(firstTool, secondTool); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.ClearTools(); + fingerprints.Add(await GetFingerprintAsync(provider)); + + // Assert + AssertFingerprintsChanged(fingerprints); + } + + [Fact] + public async Task ProvideAIContextAsync_FileMountAddReplaceRemoveAndClear_ChangeSnapshotFingerprintAsync() + { + // Arrange + using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); + var fingerprints = new List(); + + // Act + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddFileMounts(new FileMount("/host/one", "/input/data")); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddFileMounts(new FileMount("/host/two", "/input/data")); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.RemoveFileMounts("/input/data"); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddFileMounts(new FileMount("/host/three", "/input/other")); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.ClearFileMounts(); + fingerprints.Add(await GetFingerprintAsync(provider)); + + // Assert + AssertFingerprintsChanged(fingerprints); + } + + [Fact] + public async Task ProvideAIContextAsync_AllowedDomainAddReplaceRemoveAndClear_ChangeSnapshotFingerprintAsync() + { + // Arrange + using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); + var fingerprints = new List(); + + // Act + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddAllowedDomains(new AllowedDomain("https://example.com", ["GET"])); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddAllowedDomains(new AllowedDomain("https://example.com", ["POST"])); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.RemoveAllowedDomains("https://example.com"); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddAllowedDomains(new AllowedDomain("https://contoso.com")); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.ClearAllowedDomains(); + fingerprints.Add(await GetFingerprintAsync(provider)); + + // Assert + AssertFingerprintsChanged(fingerprints); + } + + [Fact] + public async Task ProvideAIContextAsync_SandboxOptionMutation_ChangesSnapshotFingerprintAsync() + { + // Arrange + var options = new HyperlightCodeActProviderOptions { HeapSize = "10Mi", StackSize = "5Mi" }; + using var provider = new HyperlightCodeActProvider(options); + + // Act + var firstFingerprint = await GetFingerprintAsync(provider); + options.HeapSize = "20Mi"; + options.StackSize = "10Mi"; + var secondFingerprint = await GetFingerprintAsync(provider); + + // Assert + Assert.NotEqual(firstFingerprint, secondFingerprint); + } + + private static void AssertFingerprintsChanged(List fingerprints) + { + for (var index = 1; index < fingerprints.Count; index++) + { + Assert.NotEqual(fingerprints[index - 1], fingerprints[index]); + } + } + + private static async Task GetFingerprintAsync(HyperlightCodeActProvider provider) + { + var context = await provider.InvokingAsync(NewInvokingContext()); + return Assert.IsType(context!.Tools!.First()).ConfigFingerprint; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs index 174c5b78679..5a528606b60 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using HyperlightSandbox.Api; using Microsoft.Agents.AI.Hyperlight.Internal; using Microsoft.Extensions.AI; @@ -38,6 +40,24 @@ public void Fingerprint_OrderInsensitive_OnTools() Assert.Equal(fp1, fp2); } + [Fact] + public void Fingerprint_StructuredToolNames_DifferentFingerprints() + { + // Arrange + var joinedName = AIFunctionFactory.Create(() => "joined", name: "a|b"); + var firstTool = AIFunctionFactory.Create(() => "first", name: "a"); + var secondTool = AIFunctionFactory.Create(() => "second", name: "b"); + + // Act + var joinedFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [joinedName], [], [], hostInputDirectory: null); + var separateFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [firstTool, secondTool], [], [], hostInputDirectory: null); + + // Assert + Assert.NotEqual(joinedFingerprint, separateFingerprint); + } + [Fact] public void Fingerprint_SameNameDifferentToolRegistryVersionIds_DifferentFingerprints() { @@ -80,6 +100,44 @@ [new FileMount("/host/a", "/input/a")], Assert.NotEqual(fpEmpty, fpMount); } + [Fact] + public void Fingerprint_StructuredMountPaths_DifferentFingerprints() + { + // Act + var joinedFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [new FileMount("b|c->d", "a")], + [], + hostInputDirectory: null); + var separateFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [new FileMount("b", "a"), new FileMount("d", "c")], + [], + hostInputDirectory: null); + + // Assert + Assert.NotEqual(joinedFingerprint, separateFingerprint); + } + + [Fact] + public void Fingerprint_DistinctMountPathComponents_DifferentFingerprints() + { + // Act + var firstFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [new FileMount("b->c", "a")], + [], + hostInputDirectory: null); + var secondFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [new FileMount("c", "a->b")], + [], + hostInputDirectory: null); + + // Assert + Assert.NotEqual(firstFingerprint, secondFingerprint); + } + [Fact] public void Fingerprint_DifferentAllowedDomains_DifferentFingerprints() { @@ -99,6 +157,156 @@ [new AllowedDomain("https://b")], Assert.NotEqual(fp1, fp2); } + [Fact] + public void Fingerprint_DistinctDomainTargetAndMethod_DifferentFingerprints() + { + // Act + var firstFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [], + [new AllowedDomain("https://a/b", ["GET"])], + hostInputDirectory: null); + var secondFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [], + [new AllowedDomain("https://a", ["b/GET"])], + hostInputDirectory: null); + + // Assert + Assert.NotEqual(firstFingerprint, secondFingerprint); + } + + [Fact] + public void Fingerprint_StructuredDomainMethods_DifferentFingerprints() + { + // Act + var joinedFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [], + [new AllowedDomain("https://a", ["GET,POST"])], + hostInputDirectory: null); + var separateFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [], + [new AllowedDomain("https://a", ["GET", "POST"])], + hostInputDirectory: null); + + // Assert + Assert.NotEqual(joinedFingerprint, separateFingerprint); + } + + [Fact] + public void Fingerprint_StructuredAllowedDomains_DifferentFingerprints() + { + // Act + var joinedFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [], + [new AllowedDomain("a", ["GET|b/POST"])], + hostInputDirectory: null); + var separateFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [], + [new AllowedDomain("a", ["GET"]), new AllowedDomain("b", ["POST"])], + hostInputDirectory: null); + + // Assert + Assert.NotEqual(joinedFingerprint, separateFingerprint); + } + + [Fact] + public void Fingerprint_OrderInsensitive_OnMountsDomainsAndMethods() + { + // Arrange + var firstMount = new FileMount("/host/a", "/input/a"); + var secondMount = new FileMount("/host/b", "/input/b"); + var firstDomain = new AllowedDomain("https://a", ["POST", "GET"]); + var secondDomain = new AllowedDomain("https://b", ["DELETE"]); + + // Act + var firstFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [firstMount, secondMount], + [firstDomain, secondDomain], + hostInputDirectory: null); + var secondFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [secondMount, firstMount], + [secondDomain, new AllowedDomain("https://a", ["GET", "POST"])], + hostInputDirectory: null); + + // Assert + Assert.Equal(firstFingerprint, secondFingerprint); + } + + [Fact] + public void Fingerprint_DifferentSandboxOptions_DifferentFingerprints() + { + // Arrange + var baseline = SandboxExecutor.RunSnapshot.ComputeFingerprint([], [], [], hostInputDirectory: null); + + // Act + var differentBackend = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], [], [], hostInputDirectory: null, backend: SandboxBackend.Wasm); + var differentModule = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], [], [], hostInputDirectory: null, modulePath: "/guest/module.wasm"); + var differentHeap = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], [], [], hostInputDirectory: null, heapSize: "20Mi"); + var differentStack = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], [], [], hostInputDirectory: null, stackSize: "10Mi"); + + // Assert + Assert.NotEqual(baseline, differentBackend); + Assert.NotEqual(baseline, differentModule); + Assert.NotEqual(baseline, differentHeap); + Assert.NotEqual(baseline, differentStack); + } + + [Fact] + public void Fingerprint_IsSha256Hex() + { + // Act + var fingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint([], [], [], hostInputDirectory: null); + + // Assert + Assert.Matches("^[0-9A-F]{64}$", fingerprint); + } + + [Fact] + public void RunSnapshot_CapturesMutableInputs() + { + // Arrange + var tools = new List { AIFunctionFactory.Create(() => "ok", name: "tool") }; + var mounts = new List { new("/host", "/input") }; + var methods = new List { "GET" }; + var domains = new List { new("https://example.com", methods) }; + var options = new HyperlightCodeActProviderOptions + { + HeapSize = "10Mi", + StackSize = "5Mi", + HostInputDirectory = "/host/input", + }; + + // Act + var snapshot = new SandboxExecutor.RunSnapshot(tools, mounts, domains, options); + tools.Clear(); + mounts.Clear(); + domains.Clear(); + methods.Add("POST"); + options.HeapSize = "20Mi"; + options.StackSize = "10Mi"; + options.HostInputDirectory = "/host/other"; + + // Assert + Assert.Single(snapshot.Tools); + Assert.Single(snapshot.FileMounts); + var domain = Assert.Single(snapshot.AllowedDomains); + Assert.Equal(["GET"], domain.Methods); + Assert.Equal("10Mi", snapshot.HeapSize); + Assert.Equal("5Mi", snapshot.StackSize); + Assert.Equal("/host/input", snapshot.HostInputDirectory); + } + [Fact] public void Fingerprint_DifferentHostInputDirectory_DifferentFingerprints() {